createWithContent($name, $content); } public function test_admin_can_upload_a_new_revision_with_files(): void { Storage::fake('project_files'); $admin = User::factory()->create(['role' => 'admin']); $project = Project::factory()->create(); $response = $this->actingAs($admin)->post(route('projects.revisions.store', $project), [ 'change_notes' => 'First cut', 'files' => [$this->stl('body.stl')], 'roles' => ['body'], ]); $response->assertRedirect(); $this->assertDatabaseHas('project_revisions', ['project_id' => $project->id, 'revision_number' => 1, 'status' => 'draft']); $revision = ProjectRevision::first(); $file = ProjectFile::where('revision_id', $revision->id)->first(); $this->assertNotNull($file); $this->assertSame('body.stl', $file->original_filename); $this->assertNotSame('body.stl', $file->storage_key); Storage::disk('project_files')->assertExists($file->storage_key); } public function test_revision_numbers_increment_per_project(): void { $admin = User::factory()->create(['role' => 'admin']); $project = Project::factory()->create(); Storage::fake('project_files'); $this->actingAs($admin)->post(route('projects.revisions.store', $project), [ 'files' => [$this->stl()], 'roles' => ['body'], ]); $this->actingAs($admin)->post(route('projects.revisions.store', $project), [ 'files' => [$this->stl()], 'roles' => ['body'], ]); $this->assertDatabaseHas('project_revisions', ['project_id' => $project->id, 'revision_number' => 1]); $this->assertDatabaseHas('project_revisions', ['project_id' => $project->id, 'revision_number' => 2]); } public function test_rejects_upload_exceeding_file_count_limit(): void { Storage::fake('project_files'); $admin = User::factory()->create(['role' => 'admin']); $project = Project::factory()->create(); $files = []; $roles = []; for ($i = 0; $i < 11; $i++) { $files[] = $this->stl("part{$i}.stl"); $roles[] = 'body'; } $this->actingAs($admin)->post(route('projects.revisions.store', $project), [ 'files' => $files, 'roles' => $roles, ])->assertSessionHasErrors('files'); } public function test_rejects_malicious_zip_disguised_as_3mf(): void { Storage::fake('project_files'); $admin = User::factory()->create(['role' => 'admin']); $project = Project::factory()->create(); $path = tempnam(sys_get_temp_dir(), 'zip'); file_put_contents($path, random_bytes(64)); $file = new UploadedFile($path, 'evil.3mf', null, null, true); $this->actingAs($admin)->post(route('projects.revisions.store', $project), [ 'files' => [$file], 'roles' => ['body'], ])->assertSessionHasErrors('files.0'); $this->assertDatabaseCount('project_files', 0); unlink($path); } public function test_failed_validation_leaves_no_orphaned_stored_bytes(): void { Storage::fake('project_files'); $admin = User::factory()->create(['role' => 'admin']); $project = Project::factory()->create(); $path = tempnam(sys_get_temp_dir(), 'zip'); file_put_contents($path, random_bytes(64)); $badFile = new UploadedFile($path, 'evil.3mf', null, null, true); $this->actingAs($admin)->post(route('projects.revisions.store', $project), [ 'files' => [$this->stl('good.stl'), $badFile], 'roles' => ['body', 'body'], ]); $this->assertDatabaseCount('project_files', 0); $this->assertCount(0, Storage::disk('project_files')->allFiles()); unlink($path); } public function test_non_validation_failure_mid_upload_cleans_up_stored_bytes(): void { Storage::fake('project_files'); $admin = User::factory()->create(['role' => 'admin']); $project = Project::factory()->create(); $calls = 0; ProjectFile::creating(function () use (&$calls) { $calls++; if ($calls === 2) { throw new \RuntimeException('Simulated DB failure mid-upload.'); } }); try { $this->actingAs($admin)->post(route('projects.revisions.store', $project), [ 'files' => [$this->stl('a.stl'), $this->stl('b.stl')], 'roles' => ['body', 'body'], ]); } finally { ProjectFile::flushEventListeners(); } $this->assertDatabaseCount('project_files', 0); $this->assertCount(0, Storage::disk('project_files')->allFiles()); } public function test_rejects_a_batch_whose_combined_size_is_excessive(): void { Storage::fake('project_files'); $admin = User::factory()->create(['role' => 'admin']); $project = Project::factory()->create(); $files = []; $roles = []; for ($i = 0; $i < 4; $i++) { $files[] = UploadedFile::fake()->create("big{$i}.stl", 90 * 1024, 'model/stl'); $roles[] = 'body'; } $this->actingAs($admin)->post(route('projects.revisions.store', $project), [ 'files' => $files, 'roles' => $roles, ])->assertSessionHasErrors('files'); $this->assertDatabaseCount('project_files', 0); } public function test_approving_a_revision_locks_in_approver_and_timestamp(): void { Storage::fake('project_files'); $admin = User::factory()->create(['role' => 'admin']); $project = Project::factory()->create(); $this->actingAs($admin)->post(route('projects.revisions.store', $project), [ 'files' => [$this->stl()], 'roles' => ['body'], ]); $revision = ProjectRevision::first(); $this->actingAs($admin)->post(route('projects.revisions.approve', [$project, $revision]))->assertRedirect(); $revision->refresh(); $this->assertSame('approved', $revision->status); $this->assertSame($admin->id, $revision->approved_by); $this->assertNotNull($revision->approved_at); } public function test_approved_revision_cannot_be_modified_by_adding_more_files(): void { Storage::fake('project_files'); $admin = User::factory()->create(['role' => 'admin']); $project = Project::factory()->create(); $revision = ProjectRevision::factory()->approved()->for($project)->create(['revision_number' => 1]); $this->actingAs($admin)->post(route('projects.revisions.store', $project), [ 'files' => [$this->stl()], 'roles' => ['body'], 'revision_id' => $revision->id, ]); // A correction always creates a new revision rather than mutating the approved one. $this->assertDatabaseHas('project_revisions', ['project_id' => $project->id, 'revision_number' => 2]); $this->assertSame(0, ProjectFile::where('revision_id', $revision->id)->count()); } public function test_guest_cannot_download_project_file(): void { Storage::fake('project_files'); $project = Project::factory()->create(); $revision = ProjectRevision::factory()->for($project)->create(); $file = ProjectFile::factory()->for($revision, 'revision')->create(); $this->get(route('projects.files.download', [$project, $file]))->assertRedirect(route('login')); } public function test_cannot_download_a_file_through_a_different_projects_url(): void { Storage::fake('project_files'); $admin = User::factory()->create(['role' => 'admin']); $projectA = Project::factory()->create(); $projectB = Project::factory()->create(); $revision = ProjectRevision::factory()->for($projectA)->create(); $file = ProjectFile::factory()->for($revision, 'revision')->create(); $this->actingAs($admin)->get(route('projects.files.download', [$projectB, $file]))->assertNotFound(); } public function test_admin_can_download_an_owned_project_file(): void { Storage::fake('project_files'); $admin = User::factory()->create(['role' => 'admin']); $project = Project::factory()->create(); $this->actingAs($admin)->post(route('projects.revisions.store', $project), [ 'files' => [$this->stl('mypart.stl')], 'roles' => ['body'], ]); $file = ProjectFile::first(); $this->actingAs($admin)->get(route('projects.files.download', [$project, $file])) ->assertOk() ->assertHeader('content-disposition', 'attachment; filename=mypart.stl'); } }