205 lines
10 KiB
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);
|
|
}
|
|
}
|