Files
Investidor-V2/tests/Feature/ResourceTechnicalIncidentsTest.php
T

248 lines
16 KiB
PHP

<?php
namespace Tests\Feature;
use App\Jobs\ScanDocumentForMalware;
use App\Models\Opportunity;
use App\Models\User;
use App\Services\OpportunityImageProcessor;
use App\Services\OpportunityManagement;
use App\Services\OpportunityResourceFileCleanup;
use App\Services\OpportunityTechnicalIncidents;
use App\Services\PortalProfileAdministration;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
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 Inertia\Testing\AssertableInertia as Assert;
use Mockery;
use Tests\Support\CleanScannerFixture;
use Tests\TestCase;
final class ResourceTechnicalIncidentsTest extends TestCase
{
use RefreshDatabase;
private User $actor;
private User $support;
private Opportunity $opportunity;
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
Http::fake();
Mail::fake();
Queue::fake();
Storage::fake('local');
config(['portal.profile_access_enabled' => true, 'suitecrm.enabled' => false, 'investmoz_ai.enabled' => false]);
$this->actor = User::factory()->create();
$this->support = User::factory()->create();
$approver = User::factory()->create();
$organization = (string) Str::uuid();
DB::table('organizations')->insert(['id' => $organization, 'name' => 'SYNTHETIC upload QA organization', 'organization_type' => 'private']);
DB::table('memberships')->insert(['id' => (string) Str::uuid(), 'user_id' => $this->actor->id, 'organization_id' => $organization, 'role' => 'officer']);
foreach ([[$this->actor, 'promoter'], [$this->support, 'technical_support']] as [$user,$role]) {
app(PortalProfileAdministration::class)->insertGrant((string) Str::uuid(), $approver, ['user_id' => $user->id, 'role' => $role, 'organization_id' => $organization, 'opportunity_types' => ['investment_project'], 'source' => 'synthetic-resource-incident-fixture']);
}
$id = (string) Str::uuid();
$this->opportunity = new Opportunity(['reference' => $id, 'slug' => $id, 'title' => 'PRIVATE SYNTHETIC upload investment', 'status' => 'draft']);
$this->opportunity->forceFill(['owner_user_id' => $this->actor->id, 'organization_id' => $organization, 'opportunity_category' => 'investment_project', 'management_version' => 4, 'workflow_context' => ['working_state' => 'draft']])->save();
$this->actingAs($this->actor);
}
private function url(string $kind = 'documents'): string
{
return '/minhas-oportunidades/'.$this->opportunity->id.'/resources/'.$kind;
}
private function input(): array
{
return ['etag' => app(OpportunityManagement::class)->etag($this->opportunity->fresh()), 'creation_key' => (string) Str::uuid(), 'reason' => 'PRIVATE SYNTHETIC upload reason', 'payload' => ['title' => 'PRIVATE SYNTHETIC original', 'classification' => 'confidential'], 'file' => UploadedFile::fake()->createWithContent('PRIVATE-synthetic.pdf', "%PDF-1.7\nSYNTHETIC private bytes\n%%EOF")];
}
private function incidentCount(): int
{
return DB::table('audit_events')->where('source_system', 'portal-opportunity-incident')->count();
}
public function test_database_failure_rolls_back_upload_preserves_prior_originals_and_routes_private_metadata_to_support(): void
{
$before = $this->opportunity->fresh()->getRawOriginal();
DB::statement("ALTER TABLE audit_events ADD CONSTRAINT qa_upload_failure CHECK (source_system <> 'opportunity-resources')");
$response = $this->post($this->url(), $this->input(), ['Accept' => 'application/json'])->assertStatus(503)->assertJsonPath('incident_recorded', true);
$reference = $response->json('reference');
$this->assertTrue(Str::isUuid($reference));
$this->assertStringNotContainsString('SQLSTATE', $response->getContent());
$this->assertStringNotContainsString('PRIVATE-synthetic', $response->getContent());
$this->assertSame($before, $this->opportunity->fresh()->getRawOriginal());
$this->assertSame(0, DB::table('documents')->count());
$this->assertSame(0, DB::table('opportunity_documents')->count());
$this->assertSame([], Storage::disk('local')->allFiles());
$data = json_decode(DB::table('audit_events')->where('id', $reference)->value('after_data'), true)['incident'];
$this->assertSame('upload_document', $data['operation']);
$this->assertSame(4, $data['observed_version']);
$this->assertSame('database_failure', $data['error_code']);
$this->assertStringNotContainsString('PRIVATE', json_encode($data));
$this->get('/minhas-oportunidades/'.$this->opportunity->id.'/resources')->assertOk()->assertHeader('Cache-Control', 'no-store, private')->assertInertia(fn (Assert $page) => $page->where('technicalIncidents.0.reference', $reference)->where('technicalIncidents.0.operation', 'upload_document'));
$this->actingAs($this->support)->get('/suporte-tecnico/incidentes?unread=1')->assertOk()->assertInertia(fn (Assert $page) => $page->where('incidents.total', 1)->where('incidents.data.0.operation', 'upload_document'));
$this->get('/minhas-oportunidades/'.$this->opportunity->id.'/resources')->assertForbidden();
DB::statement('ALTER TABLE audit_events DROP CONSTRAINT qa_upload_failure');
$this->actingAs($this->actor)->post($this->url(), $this->input(), ['Accept' => 'application/json'])->assertCreated();
$this->assertSame(1, $this->incidentCount());
}
public function test_expected_validation_scope_version_and_quarantine_errors_do_not_create_incidents(): void
{
$bad = $this->input();
$bad['file'] = UploadedFile::fake()->createWithContent('bad.pdf', 'not a pdf')->mimeType('text/plain');
$this->post($this->url(), $bad, ['Accept' => 'application/json'])->assertUnprocessable();
$stale = $this->input();
$stale['etag'] = str_repeat('0', 64);
$this->post($this->url(), $stale, ['Accept' => 'application/json'])->assertConflict();
$this->actingAs($this->support)->post($this->url(), $this->input(), ['Accept' => 'application/json'])->assertForbidden();
$this->actingAs($this->actor)->postJson($this->url('images').'/'.Str::uuid().'/prepare', ['etag' => app(OpportunityManagement::class)->etag($this->opportunity), 'version' => 1, 'reason' => 'SYNTHETIC missing image request'])->assertStatus(function_exists('imagecreatefromstring') ? 404 : 423);
$this->assertSame(0, $this->incidentCount());
$this->assertSame([], Storage::disk('local')->allFiles());
}
public function test_partial_storage_failure_is_reported_and_cleanup_failure_does_not_hide_the_original_result(): void
{
$disk = Storage::disk('local');
$fault = Mockery::mock($disk);
$fault->shouldReceive('putFileAs')->andReturnUsing(function ($path, $file, $name) use ($disk) {
$disk->put($path.'/'.$name, 'SYNTHETIC partial bytes');
return false;
});
$fault->shouldReceive('delete')->andThrow(new \RuntimeException('PRIVATE cleanup failure'));
Storage::shouldReceive('disk')->with('local')->andReturn($fault);
$response = $this->post($this->url(), $this->input(), ['Accept' => 'application/json'])->assertStatus(503)->assertJsonPath('incident_recorded', true);
$this->assertStringNotContainsString('PRIVATE', $response->getContent());
$this->assertCount(1, $disk->allFiles());
$this->assertSame(0, DB::table('documents')->count());
$this->assertSame(1, $this->incidentCount());
}
public function test_cleanup_preserves_referenced_originals_uncertain_commit_bytes_and_failed_reference_checks(): void
{
$cleanup = app(OpportunityResourceFileCleanup::class);
$disk = Storage::disk('local');
$key = 'opportunity-resources/'.Str::uuid();
$disk->put($key, Crypt::encryptString('SYNTHETIC preserved original'));
$hash = hash('sha256', $disk->get($key));
DB::table('documents')->insert(['id' => (string) Str::uuid(), 'source_system' => 'opportunity-resources', 'file_name' => 'synthetic.pdf', 'media_type' => 'application/pdf', 'storage_key' => $key, 'sha256' => str_repeat('a', 64), 'status' => 'quarantined', 'storage_encryption' => 'laravel', 'data_classification' => 'restricted', 'size_bytes' => 1, 'uploaded_by' => $this->actor->id]);
$cleanup->afterFailure([$key], new \RuntimeException('SYNTHETIC failure after committed document'));
$this->assertSame($hash, hash('sha256', $disk->get($key)));
$unknown = 'opportunity-resources/'.Str::uuid();
$disk->put($unknown, 'SYNTHETIC uncertain bytes');
$cleanup->afterFailure([$unknown], new \PDOException('server closed the connection unexpectedly'));
$this->assertTrue($disk->exists($unknown));
DB::statement('ALTER TABLE documents RENAME COLUMN storage_key TO qa_unavailable_storage_key');
$cleanup->afterFailure([$unknown], new \RuntimeException('SYNTHETIC failed reference lookup'));
$this->assertTrue($disk->exists($unknown));
$this->assertGreaterThan(0, DB::table('users')->count());
DB::statement('ALTER TABLE documents RENAME COLUMN qa_unavailable_storage_key TO storage_key');
$cleanup->afterFailure([$unknown], new \RuntimeException('SYNTHETIC confirmed rollback'));
$this->assertFalse($disk->exists($unknown));
}
public function test_failed_incident_persistence_is_reported_honestly_and_does_not_leak_the_upload(): void
{
DB::statement("ALTER TABLE audit_events ADD CONSTRAINT qa_no_resource_audit CHECK (source_system NOT IN ('opportunity-resources','portal-opportunity-incident'))");
$response = $this->post($this->url(), $this->input(), ['Accept' => 'application/json'])->assertStatus(503)->assertJsonPath('incident_recorded', false);
$this->assertTrue(Str::isUuid($response->json('reference')));
$this->assertSame(0, $this->incidentCount());
$this->assertSame([], Storage::disk('local')->allFiles());
$this->assertStringNotContainsString('PRIVATE', $response->getContent());
}
public function test_filtered_rls_visibility_never_authorizes_removal_of_a_referenced_original(): void
{
$key = 'opportunity-resources/'.Str::uuid();
$disk = Storage::disk('local');
$disk->put($key, 'SYNTHETIC original behind RLS');
DB::table('documents')->insert(['id' => (string) Str::uuid(), 'source_system' => 'opportunity-resources', 'file_name' => 'synthetic-rls.pdf', 'media_type' => 'application/pdf', 'storage_key' => $key, 'sha256' => hash('sha256', 'SYNTHETIC original behind RLS'), 'status' => 'quarantined', 'size_bytes' => 1, 'uploaded_by' => $this->actor->id]);
DB::statement('ALTER TABLE documents ENABLE ROW LEVEL SECURITY');
DB::statement('ALTER TABLE documents FORCE ROW LEVEL SECURITY');
DB::statement('SET LOCAL ROLE pg_read_all_data');
try {
$this->assertTrue(DB::selectOne("SELECT row_security_active('documents'::regclass) AS active")->active);
$this->assertSame(0, DB::table('documents')->where('storage_key', $key)->count());
app(OpportunityResourceFileCleanup::class)->afterFailure([$key], new \RuntimeException('SYNTHETIC failure with filtered reference visibility'));
$this->assertTrue($disk->exists($key));
} finally {
DB::statement('RESET ROLE');
}
$this->assertSame(1, DB::table('documents')->where('storage_key', $key)->count());
$this->assertSame('investmoz_test', DB::selectOne('SELECT current_user AS role')->role);
}
private function png(): string
{
$chunk = fn ($name, $bytes) => pack('N', strlen($bytes)).$name.$bytes.pack('N', crc32($name.$bytes));
return "\x89PNG\r\n\x1a\n".$chunk('IHDR', pack('NNCCCCC', 500, 500, 8, 2, 0, 0, 0)).$chunk('IDAT', gzcompress(str_repeat("\0", 500 * 1501))).$chunk('IEND', '');
}
public function test_image_upload_and_preparation_failures_have_distinct_labels_and_no_automatic_retry(): void
{
$input = $this->input();
$input['payload'] = ['title' => 'SYNTHETIC image', 'alt_text' => 'SYNTHETIC plain image', 'caption' => 'SYNTHETIC image caption', 'rights_status' => 'owned'];
$input['file'] = UploadedFile::fake()->createWithContent('synthetic.png', $this->png());
DB::statement("ALTER TABLE audit_events ADD CONSTRAINT qa_image_upload_failure CHECK (source_system <> 'opportunity-resources')");
$this->post($this->url('images'), $input, ['Accept' => 'application/json'])->assertStatus(503)->assertJsonPath('incident_recorded', true);
DB::statement('ALTER TABLE audit_events DROP CONSTRAINT qa_image_upload_failure');
$this->assertSame('upload_image', app(OpportunityTechnicalIncidents::class)->recent($this->actor, $this->opportunity)[0]['operation']);
$processor = Mockery::mock(OpportunityImageProcessor::class);
$processor->shouldReceive('prepare')->once()->andThrow(new \RuntimeException('PRIVATE SYNTHETIC processor failure'));
$this->app->instance(OpportunityImageProcessor::class, $processor);
$this->postJson($this->url('images').'/'.Str::uuid().'/prepare', ['etag' => app(OpportunityManagement::class)->etag($this->opportunity), 'version' => 1, 'reason' => 'SYNTHETIC explicit image processing'])->assertStatus(503)->assertJsonPath('incident_recorded', true);
$operations = array_column(app(OpportunityTechnicalIncidents::class)->recent($this->actor, $this->opportunity), 'operation');
$this->assertContains('prepare_image', $operations);
$this->assertContains('upload_image', $operations);
$this->assertSame(2, $this->incidentCount());
}
public function test_derivative_partial_storage_failure_preserves_original_and_cleans_new_unreferenced_bytes(): void
{
if (! app(OpportunityImageProcessor::class)->available()) {
$this->postJson($this->url('images').'/'.Str::uuid().'/prepare', ['etag' => app(OpportunityManagement::class)->etag($this->opportunity), 'version' => 1, 'reason' => 'SYNTHETIC unavailable processing'])->assertStatus(423);
$this->assertSame(0, $this->incidentCount());
return;
}
$input = $this->input();
$input['payload'] = ['title' => 'SYNTHETIC image', 'alt_text' => 'SYNTHETIC plain image', 'caption' => 'SYNTHETIC image caption', 'rights_status' => 'owned'];
$input['file'] = UploadedFile::fake()->createWithContent('synthetic.png', $this->png());
$id = $this->post($this->url('images'), $input, ['Accept' => 'application/json'])->assertCreated()->json('id');
$image = DB::table('opportunity_images')->where('id', $id)->first();
(new ScanDocumentForMalware($image->document_id))->handle(new CleanScannerFixture);
$original = DB::table('documents')->where('id', $image->document_id)->first();
$disk = Storage::disk('local');
$before = $disk->get($original->storage_key);
$opportunityBefore = $this->opportunity->fresh()->getRawOriginal();
$fault = Mockery::mock($disk);
$fault->shouldReceive('put')->andReturnUsing(function ($key, $bytes) use ($disk) {
$disk->put($key, $bytes);
return false;
});
Storage::shouldReceive('disk')->with('local')->andReturn($fault);
$this->postJson($this->url('images').'/'.$id.'/prepare', ['etag' => app(OpportunityManagement::class)->etag($this->opportunity->fresh()), 'version' => 1, 'reason' => 'SYNTHETIC derivative storage failure'])->assertStatus(503)->assertJsonPath('incident_recorded', true);
$this->assertSame([$original->storage_key], $disk->allFiles());
$this->assertSame($before, $disk->get($original->storage_key));
$this->assertEquals($image, DB::table('opportunity_images')->where('id', $id)->first());
$this->assertSame($opportunityBefore, $this->opportunity->fresh()->getRawOriginal());
$this->assertSame(1, DB::table('documents')->count());
$this->assertSame('prepare_image', app(OpportunityTechnicalIncidents::class)->recent($this->actor, $this->opportunity)[0]['operation']);
}
}