310 lines
14 KiB
PHP
310 lines
14 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Models\Project;
|
|
use App\Models\ProjectEvent;
|
|
use App\Models\ProjectFile;
|
|
use App\Models\ProjectFileRender;
|
|
use App\Models\ProjectRevision;
|
|
use App\Models\User;
|
|
use App\Services\ThreeMfGeometryExtractor;
|
|
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Tests\TestCase;
|
|
use ZipArchive;
|
|
|
|
class ProjectFileRenderTest extends TestCase
|
|
{
|
|
use LazilyRefreshDatabase;
|
|
|
|
private const NS = 'http://schemas.microsoft.com/3dmanufacturing/core/2015/02';
|
|
|
|
private function validModelXml(): string
|
|
{
|
|
return '<?xml version="1.0"?><model xmlns="'.self::NS.'"><resources>
|
|
<object id="1" type="model">
|
|
<mesh>
|
|
<vertices>
|
|
<vertex x="0" y="0" z="0"/>
|
|
<vertex x="10" y="0" z="0"/>
|
|
<vertex x="0" y="10" z="0"/>
|
|
</vertices>
|
|
<triangles><triangle v1="0" v2="1" v3="2"/></triangles>
|
|
</mesh>
|
|
</object>
|
|
</resources><build><item objectid="1"/></build></model>';
|
|
}
|
|
|
|
private function validThreeMfBytes(): string
|
|
{
|
|
$path = tempnam(sys_get_temp_dir(), 'tmf').'.3mf';
|
|
$zip = new ZipArchive;
|
|
$zip->open($path, ZipArchive::CREATE);
|
|
$zip->addFromString('[Content_Types].xml', '<?xml version="1.0"?><Types xmlns="x"/>');
|
|
$zip->addFromString('3D/3dmodel.model', $this->validModelXml());
|
|
$zip->addFromString('_rels/.rels', '<?xml version="1.0"?><Relationships xmlns="x"><Relationship Target="/3D/3dmodel.model" Id="rel0" Type="y"/></Relationships>');
|
|
$zip->close();
|
|
$bytes = file_get_contents($path);
|
|
unlink($path);
|
|
|
|
return $bytes;
|
|
}
|
|
|
|
private function maliciousThreeMfBytes(): string
|
|
{
|
|
$path = tempnam(sys_get_temp_dir(), 'tmf').'.3mf';
|
|
$zip = new ZipArchive;
|
|
$zip->open($path, ZipArchive::CREATE);
|
|
$zip->addFromString('[Content_Types].xml', '<?xml version="1.0"?><Types xmlns="x"/>');
|
|
$zip->addFromString('3D/3dmodel.model', $this->validModelXml());
|
|
$zip->addFromString('bomb.bin', str_repeat("\0", 20 * 1024 * 1024));
|
|
$zip->setCompressionName('bomb.bin', ZipArchive::CM_DEFLATE, 9);
|
|
$zip->close();
|
|
$bytes = file_get_contents($path);
|
|
unlink($path);
|
|
|
|
return $bytes;
|
|
}
|
|
|
|
/**
|
|
* @return array{0: Project, 1: ProjectFile}
|
|
*/
|
|
private function projectWith3mfFile(string $bytes): array
|
|
{
|
|
$project = Project::factory()->create();
|
|
$revision = ProjectRevision::factory()->for($project)->create();
|
|
$key = 'source.3mf';
|
|
Storage::disk('project_files')->put($key, $bytes);
|
|
$file = ProjectFile::factory()->for($revision, 'revision')->create([
|
|
'extension' => '3mf', 'media_type' => 'model/3mf', 'storage_key' => $key, 'original_filename' => 'widget.3mf',
|
|
]);
|
|
|
|
return [$project, $file];
|
|
}
|
|
|
|
public function test_generation_is_inside_the_serializing_database_transaction(): void
|
|
{
|
|
Storage::fake('project_files');
|
|
Storage::fake('project_renders');
|
|
[$project, $file] = $this->projectWith3mfFile($this->validThreeMfBytes());
|
|
$scene = (new ThreeMfGeometryExtractor)->extract(Storage::disk('project_files')->path($file->storage_key));
|
|
$level = DB::transactionLevel();
|
|
$this->mock(ThreeMfGeometryExtractor::class)->shouldReceive('extract')->once()->andReturnUsing(function () use ($scene, $level): array {
|
|
$this->assertGreaterThan($level, DB::transactionLevel());
|
|
|
|
return $scene;
|
|
});
|
|
$this->actingAs(User::factory()->create(['role' => 'admin']))
|
|
->post(route('projects.files.render', [$project, $file]))->assertRedirect();
|
|
}
|
|
|
|
public function test_failed_render_storage_does_not_create_a_render_record(): void
|
|
{
|
|
Storage::fake('project_files');
|
|
Storage::fake('project_renders');
|
|
[$project, $file] = $this->projectWith3mfFile($this->validThreeMfBytes());
|
|
$disk = \Mockery::mock(Storage::disk('project_renders'))->makePartial();
|
|
$disk->shouldReceive('put')->once()->andReturnFalse();
|
|
Storage::set('project_renders', $disk);
|
|
$this->actingAs(User::factory()->create(['role' => 'admin']))
|
|
->post(route('projects.files.render', [$project, $file]))->assertStatus(422);
|
|
$this->assertDatabaseCount('project_file_renders', 0);
|
|
}
|
|
|
|
public function test_audit_failure_rolls_back_the_record_and_cleans_up_generated_bytes(): void
|
|
{
|
|
Storage::fake('project_files');
|
|
Storage::fake('project_renders');
|
|
[$project, $file] = $this->projectWith3mfFile($this->validThreeMfBytes());
|
|
ProjectEvent::creating(function (): void {
|
|
throw new \RuntimeException('Synthetic audit storage failure');
|
|
});
|
|
try {
|
|
$this->actingAs(User::factory()->create(['role' => 'admin']))
|
|
->post(route('projects.files.render', [$project, $file]))->assertStatus(422);
|
|
$this->assertDatabaseCount('project_file_renders', 0);
|
|
$this->assertSame([], Storage::disk('project_renders')->allFiles());
|
|
} finally {
|
|
ProjectEvent::flushEventListeners();
|
|
}
|
|
}
|
|
|
|
public function test_admin_can_generate_a_product_image_from_a_valid_3mf_file(): void
|
|
{
|
|
Storage::fake('project_files');
|
|
Storage::fake('project_renders');
|
|
$admin = User::factory()->create(['role' => 'admin']);
|
|
[$project, $file] = $this->projectWith3mfFile($this->validThreeMfBytes());
|
|
|
|
$this->actingAs($admin)->post(route('projects.files.render', [$project, $file]))
|
|
->assertRedirect(route('projects.show', $project));
|
|
|
|
$this->assertDatabaseCount('project_file_renders', 1);
|
|
$render = ProjectFileRender::first();
|
|
$this->assertSame($file->id, $render->project_file_id);
|
|
$this->assertSame(1, $render->triangle_count);
|
|
$this->assertSame(1, $render->instance_count);
|
|
$this->assertStringContainsString('widget.3mf', $render->alt_text);
|
|
Storage::disk('project_renders')->assertExists($render->storage_key);
|
|
$this->assertDatabaseHas('project_events', ['project_id' => $project->id, 'event_type' => 'product_image_generated']);
|
|
}
|
|
|
|
public function test_generating_does_not_modify_the_original_file(): void
|
|
{
|
|
Storage::fake('project_files');
|
|
Storage::fake('project_renders');
|
|
$admin = User::factory()->create(['role' => 'admin']);
|
|
$bytes = $this->validThreeMfBytes();
|
|
[$project, $file] = $this->projectWith3mfFile($bytes);
|
|
$before = Storage::disk('project_files')->get($file->storage_key);
|
|
|
|
$this->actingAs($admin)->post(route('projects.files.render', [$project, $file]));
|
|
|
|
$after = Storage::disk('project_files')->get($file->storage_key);
|
|
$this->assertSame($before, $after);
|
|
$this->assertSame(hash('sha256', $bytes), hash('sha256', $after));
|
|
}
|
|
|
|
public function test_calling_generate_twice_is_idempotent_and_creates_only_one_render(): void
|
|
{
|
|
Storage::fake('project_files');
|
|
Storage::fake('project_renders');
|
|
$admin = User::factory()->create(['role' => 'admin']);
|
|
[$project, $file] = $this->projectWith3mfFile($this->validThreeMfBytes());
|
|
|
|
$this->actingAs($admin)->post(route('projects.files.render', [$project, $file]))->assertRedirect();
|
|
$firstKey = ProjectFileRender::first()->storage_key;
|
|
|
|
$this->actingAs($admin)->post(route('projects.files.render', [$project, $file]))
|
|
->assertRedirect(route('projects.show', $project))
|
|
->assertSessionHas('status', 'Product image already generated for this file.');
|
|
|
|
$this->assertDatabaseCount('project_file_renders', 1);
|
|
$this->assertSame($firstKey, ProjectFileRender::first()->storage_key);
|
|
$this->assertDatabaseCount('project_events', 1);
|
|
}
|
|
|
|
public function test_rejects_generation_for_a_non_3mf_file(): void
|
|
{
|
|
Storage::fake('project_files');
|
|
$admin = User::factory()->create(['role' => 'admin']);
|
|
$project = Project::factory()->create();
|
|
$revision = ProjectRevision::factory()->for($project)->create();
|
|
$file = ProjectFile::factory()->for($revision, 'revision')->create(['extension' => 'stl']);
|
|
|
|
$this->actingAs($admin)->post(route('projects.files.render', [$project, $file]))
|
|
->assertSessionHasErrors('file');
|
|
$this->assertDatabaseCount('project_file_renders', 0);
|
|
}
|
|
|
|
public function test_rejects_a_malicious_archive_with_a_clear_error_and_no_render_created(): void
|
|
{
|
|
Storage::fake('project_files');
|
|
Storage::fake('project_renders');
|
|
$admin = User::factory()->create(['role' => 'admin']);
|
|
[$project, $file] = $this->projectWith3mfFile($this->maliciousThreeMfBytes());
|
|
|
|
$this->actingAs($admin)->post(route('projects.files.render', [$project, $file]))
|
|
->assertSessionHasErrors('file');
|
|
|
|
$this->assertDatabaseCount('project_file_renders', 0);
|
|
$this->assertCount(0, Storage::disk('project_renders')->allFiles());
|
|
}
|
|
|
|
public function test_guest_cannot_generate_preview_or_download(): void
|
|
{
|
|
Storage::fake('project_files');
|
|
[$project, $file] = $this->projectWith3mfFile($this->validThreeMfBytes());
|
|
|
|
$this->post(route('projects.files.render', [$project, $file]))->assertRedirect(route('login'));
|
|
$this->get(route('projects.files.render.preview', [$project, $file]))->assertRedirect(route('login'));
|
|
$this->get(route('projects.files.render.download', [$project, $file]))->assertRedirect(route('login'));
|
|
}
|
|
|
|
public function test_non_admin_cannot_generate_preview_or_download(): void
|
|
{
|
|
Storage::fake('project_files');
|
|
$user = User::factory()->create();
|
|
[$project, $file] = $this->projectWith3mfFile($this->validThreeMfBytes());
|
|
|
|
$this->actingAs($user)->post(route('projects.files.render', [$project, $file]))->assertForbidden();
|
|
$this->actingAs($user)->get(route('projects.files.render.preview', [$project, $file]))->assertForbidden();
|
|
$this->actingAs($user)->get(route('projects.files.render.download', [$project, $file]))->assertForbidden();
|
|
}
|
|
|
|
public function test_cannot_reach_a_file_through_a_different_projects_url(): void
|
|
{
|
|
Storage::fake('project_files');
|
|
$admin = User::factory()->create(['role' => 'admin']);
|
|
$otherProject = Project::factory()->create();
|
|
[, $file] = $this->projectWith3mfFile($this->validThreeMfBytes());
|
|
|
|
$this->actingAs($admin)->post(route('projects.files.render', [$otherProject, $file]))->assertNotFound();
|
|
$this->actingAs($admin)->get(route('projects.files.render.preview', [$otherProject, $file]))->assertNotFound();
|
|
$this->actingAs($admin)->get(route('projects.files.render.download', [$otherProject, $file]))->assertNotFound();
|
|
}
|
|
|
|
public function test_preview_and_download_404_before_a_render_exists(): void
|
|
{
|
|
Storage::fake('project_files');
|
|
$admin = User::factory()->create(['role' => 'admin']);
|
|
[$project, $file] = $this->projectWith3mfFile($this->validThreeMfBytes());
|
|
|
|
$this->actingAs($admin)->get(route('projects.files.render.preview', [$project, $file]))->assertNotFound();
|
|
$this->actingAs($admin)->get(route('projects.files.render.download', [$project, $file]))->assertNotFound();
|
|
}
|
|
|
|
public function test_preview_returns_the_image_with_the_correct_content_type(): void
|
|
{
|
|
Storage::fake('project_files');
|
|
Storage::fake('project_renders');
|
|
$admin = User::factory()->create(['role' => 'admin']);
|
|
[$project, $file] = $this->projectWith3mfFile($this->validThreeMfBytes());
|
|
$this->actingAs($admin)->post(route('projects.files.render', [$project, $file]));
|
|
$render = ProjectFileRender::first();
|
|
|
|
$this->actingAs($admin)->get(route('projects.files.render.preview', [$project, $file]))
|
|
->assertOk()
|
|
->assertHeader('Content-Type', $render->mime_type);
|
|
}
|
|
|
|
public function test_download_returns_an_attachment_named_after_the_original_file(): void
|
|
{
|
|
Storage::fake('project_files');
|
|
Storage::fake('project_renders');
|
|
$admin = User::factory()->create(['role' => 'admin']);
|
|
[$project, $file] = $this->projectWith3mfFile($this->validThreeMfBytes());
|
|
$this->actingAs($admin)->post(route('projects.files.render', [$project, $file]));
|
|
$render = ProjectFileRender::first();
|
|
$extension = $render->mime_type === 'image/png' ? 'png' : 'svg';
|
|
|
|
$this->actingAs($admin)->get(route('projects.files.render.download', [$project, $file]))
|
|
->assertOk()
|
|
->assertHeader('content-disposition', 'attachment; filename='.$file->original_filename.'.'.$extension);
|
|
}
|
|
|
|
public function test_viewing_the_project_page_never_triggers_rendering(): void
|
|
{
|
|
Storage::fake('project_files');
|
|
$admin = User::factory()->create(['role' => 'admin']);
|
|
[$project, $file] = $this->projectWith3mfFile($this->validThreeMfBytes());
|
|
|
|
$this->actingAs($admin)->get(route('projects.show', $project))->assertOk();
|
|
|
|
$this->assertDatabaseCount('project_file_renders', 0);
|
|
}
|
|
|
|
public function test_missing_csrf_token_is_rejected_when_verification_actually_runs(): void
|
|
{
|
|
$this->app['env'] = 'local';
|
|
Storage::fake('project_files');
|
|
$admin = User::factory()->create(['role' => 'admin']);
|
|
[$project, $file] = $this->projectWith3mfFile($this->validThreeMfBytes());
|
|
|
|
$this->actingAs($admin)->post(route('projects.files.render', [$project, $file]))
|
|
->assertStatus(419);
|
|
}
|
|
}
|