Files
Investidor-V2/tests/Feature/DocumentScanRetryTest.php

205 lines
10 KiB
PHP

<?php
namespace Tests\Feature;
use App\Jobs\ScanDocumentForMalware;
use App\Models\User;
use App\Services\Malware\DocumentScanDelivery;
use App\Services\Malware\DocumentScanRetry;
use App\Services\PortalProfileAdministration;
use Illuminate\Bus\UniqueLock;
use Illuminate\Contracts\Cache\Repository;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Context;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Testing\TestResponse;
use Tests\Support\CleanScannerFixture;
use Tests\TestCase;
final class DocumentScanRetryTest extends TestCase
{
use RefreshDatabase;
private User $manager;
private string $id;
private string $cipher;
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
Http::fake();
Mail::fake();
Queue::fake();
Storage::fake('local');
$this->manager = User::factory()->create();
config(['portal.managers' => [$this->manager->email], 'portal.document_managers' => [], 'suitecrm.enabled' => false, 'investmoz_ai.enabled' => false]);
$this->actingAs($this->manager);
$this->id = (string) Str::uuid();
$bytes = "%PDF-1.7\nSYNTHETIC retry document\n%%EOF";
$this->cipher = Crypt::encryptString($bytes);
Storage::disk('local')->put('opportunity-resources/'.$this->id, $this->cipher);
DB::table('documents')->insert(['id' => $this->id, 'source_system' => 'portal-services', 'file_name' => 'PRIVATE-synthetic.pdf', 'storage_key' => 'opportunity-resources/'.$this->id,
'sha256' => hash('sha256', $bytes), 'size_bytes' => strlen($bytes), 'media_type' => 'application/pdf', 'status' => 'quarantined', 'data_classification' => 'restricted',
'storage_encryption' => 'laravel', 'uploaded_by' => $this->manager->id, 'malware_scan_status' => 'inconclusive', 'malware_failure_reason' => 'scanner_unavailable']);
}
private function retry(): TestResponse
{
return $this->from('/management/portal')->post('/management/documents/'.$this->id.'/malware/retry');
}
private function document(): \stdClass
{
return DB::table('documents')->where('id', $this->id)->first();
}
private function assertOriginal(): void
{
$this->assertSame($this->cipher, Storage::disk('local')->get('opportunity-resources/'.$this->id));
$this->assertSame(1, DB::table('documents')->count());
}
public function test_http_retry_confirms_dispatch_acceptance_and_audits_the_existing_manager(): void
{
$this->retry()->assertRedirect('/management/portal')->assertSessionHas('success', fn ($value) => str_contains($value, 'aceite pelo serviço de envio'));
Queue::assertPushed(ScanDocumentForMalware::class, 1);
$this->assertSame('pending', $this->document()->malware_scan_status);
$this->assertSame('quarantined', $this->document()->status);
$event = DB::table('audit_events')->where('action', 'scan_retry_requested')->sole();
$this->assertSame($this->manager->id, $event->actor_user_id);
$data = json_decode($event->after_data, true);
$this->assertSame($this->id, $data['document_id']);
$this->assertTrue(Str::isUuid($data['reference']));
$this->assertStringNotContainsString('PRIVATE', $event->after_data);
$this->assertOriginal();
}
public function test_an_existing_unique_lease_neither_resets_failure_nor_claims_another_push(): void
{
$job = new ScanDocumentForMalware($this->id);
$this->assertTrue((new UniqueLock(app(Repository::class)))->acquire($job));
$before = $this->document();
$this->retry()->assertRedirect()->assertSessionMissing('success')->assertSessionHas('warning', fn ($value) => str_contains($value, 'reserva de análise'));
Queue::assertNothingPushed();
$this->assertEquals($before, $this->document());
$this->assertSame(0, DB::table('audit_events')->where('action', 'scan_retry_requested')->count());
$this->assertOriginal();
}
public function test_transport_failure_is_reported_as_uncertain_and_does_not_erase_the_original(): void
{
Bus::shouldReceive('dispatch')->once()->andThrow(new \RuntimeException('PRIVATE queue password and SQL'));
$this->retry()->assertRedirect()->assertSessionMissing('success')->assertSessionHas('warning', fn ($value) => str_contains($value, 'Não foi possível confirmar'));
$this->assertStringNotContainsString('PRIVATE', session('warning'));
$this->assertSame('failed', $this->document()->malware_scan_status);
$this->assertSame('quarantined', $this->document()->status);
$this->assertSame(0, DB::table('document_malware_scans')->count());
$this->assertNull(Context::getHidden('laravel_unique_job_key'));
$this->assertFalse((new UniqueLock(app(Repository::class)))->acquire(new ScanDocumentForMalware($this->id)));
$this->assertOriginal();
}
public function test_lost_acknowledgement_after_a_sync_clean_result_does_not_requarantine_it(): void
{
Bus::shouldReceive('dispatch')->once()->andReturnUsing(function ($job) {
$job->handle(new CleanScannerFixture);
throw new \RuntimeException('PRIVATE acknowledgement lost');
});
$this->retry()->assertRedirect()->assertSessionHas('warning')->assertSessionMissing('success');
$this->assertSame('clean', $this->document()->malware_scan_status);
$this->assertSame('active', $this->document()->status);
$this->assertSame(1, DB::table('document_malware_scans')->count());
$this->assertOriginal();
}
public function test_outer_rollback_discards_the_request_and_a_later_clean_result_blocks_a_stale_callback(): void
{
DB::beginTransaction();
$result = app(DocumentScanRetry::class)->request($this->manager, $this->id);
$this->assertSame('deferred', $result['outcome']);
DB::rollBack();
Queue::assertNothingPushed();
$this->assertSame('inconclusive', $this->document()->malware_scan_status);
DB::beginTransaction();
app(DocumentScanRetry::class)->request($this->manager, $this->id);
DB::table('documents')->where('id', $this->id)->update(['status' => 'active', 'malware_scan_status' => 'clean']);
DB::commit();
Queue::assertNothingPushed();
$this->assertSame('clean', $this->document()->malware_scan_status);
$this->assertTrue((new UniqueLock(app(Repository::class)))->acquire(new ScanDocumentForMalware($this->id)));
$this->assertSame(0, DB::table('audit_events')->where('action', 'scan_retry_requested')->count());
}
public function test_current_permission_is_rechecked_after_commit_before_any_scan_state_change(): void
{
DB::beginTransaction();
app(DocumentScanRetry::class)->request($this->manager, $this->id);
config(['portal.managers' => []]);
DB::commit();
Queue::assertNothingPushed();
$this->assertSame('inconclusive', $this->document()->malware_scan_status);
$this->assertSame(0, DB::table('audit_events')->where('action', 'scan_retry_requested')->count());
$this->assertTrue((new UniqueLock(app(Repository::class)))->acquire(new ScanDocumentForMalware($this->id)));
}
public function test_support_without_document_management_authority_cannot_request_a_scan(): void
{
$support = User::factory()->create();
app(PortalProfileAdministration::class)->insertGrant((string) Str::uuid(), $this->manager, ['user_id' => $support->id, 'role' => 'technical_support', 'opportunity_types' => ['investment_project'], 'source' => 'synthetic-retry-support-fixture']);
$this->actingAs($support);
$this->retry()->assertForbidden();
Queue::assertNothingPushed();
$this->assertSame('inconclusive', $this->document()->malware_scan_status);
}
public function test_clean_infected_scanning_and_deleted_documents_are_not_reset_by_retry(): void
{
foreach (['clean', 'infected', 'scanning'] as $state) {
DB::table('documents')->where('id', $this->id)->update(['malware_scan_status' => $state]);
$this->retry()->assertStatus(422);
$this->assertSame($state, $this->document()->malware_scan_status);
}
DB::table('documents')->where('id', $this->id)->update(['status' => 'deleted', 'deleted_at' => now()]);
$this->retry()->assertNotFound();
Queue::assertNothingPushed();
}
public function test_different_documents_have_independent_delivery_leases_and_stale_preparation_can_be_retried(): void
{
$first = new ScanDocumentForMalware($this->id);
$delivery = app(DocumentScanDelivery::class);
$this->assertSame('stale', $delivery->dispatch($first, fn () => false));
$this->assertSame('accepted', $delivery->dispatch(new ScanDocumentForMalware($this->id)));
$this->assertSame('duplicate', $delivery->dispatch(new ScanDocumentForMalware($this->id)));
$this->assertSame('accepted', $delivery->dispatch(new ScanDocumentForMalware((string) Str::uuid())));
Queue::assertPushed(ScanDocumentForMalware::class, 2);
}
public function test_json_retry_has_no_redirect_and_distinguishes_duplicate_from_transport_failure(): void
{
$url = '/management/documents/'.$this->id.'/malware/retry';
$this->postJson($url)->assertOk()->assertJsonPath('outcome', 'accepted')->assertHeader('Cache-Control', 'no-store, private');
$this->postJson($url)->assertOk()->assertJsonPath('outcome', 'duplicate');
Queue::assertPushed(ScanDocumentForMalware::class, 1);
}
public function test_json_transport_failure_returns_only_a_safe_reference_with_the_uncertain_status(): void
{
Bus::shouldReceive('dispatch')->once()->andThrow(new \RuntimeException('PRIVATE transport payload'));
$response = $this->postJson('/management/documents/'.$this->id.'/malware/retry')->assertStatus(503)->assertJsonPath('outcome', 'unconfirmed');
$this->assertTrue(Str::isUuid($response->json('reference')));
$this->assertStringNotContainsString('PRIVATE', $response->getContent());
$this->assertSame('failed', $this->document()->malware_scan_status);
}
}