tenant = Tenant::factory()->create(); $this->user = User::factory()->create(['tenant_id' => $this->tenant->id]); Storage::fake('local'); }); function doneReportJob(int $tenantId, int $userId, array $overrides = []): ReportJob { return ReportJob::create(array_merge([ 'tenant_id' => $tenantId, 'user_id' => $userId, 'type' => 'deals_export', 'parameters' => ['format' => 'csv', 'date_from' => '2026-04-01', 'date_to' => '2026-04-30'], 'status' => ReportJob::STATUS_DONE, 'file_path' => null, 'file_size' => null, 'finished_at' => Carbon::now(), 'expires_at' => Carbon::now()->addDays(30), ], $overrides)); } function signedDownloadUrl(ReportJob $job, ?Carbon $expiry = null): string { return URL::temporarySignedRoute( 'reports.download', $expiry ?? Carbon::now()->addHours(24), ['id' => $job->id, 'tenant' => $job->tenant_id], ); } test('download: success → 200 + attachment файла', function () { $path = "reports/{$this->tenant->id}/1.csv"; Storage::disk('local')->put($path, "col\r\nval\r\n"); $job = doneReportJob($this->tenant->id, $this->user->id, ['file_path' => $path]); $response = $this->get(signedDownloadUrl($job)); $response->assertOk(); $response->assertDownload("report-{$job->id}.csv"); }); test('download: невалидная подпись → 403', function () { $job = doneReportJob($this->tenant->id, $this->user->id, ['file_path' => 'reports/x/1.csv']); $this->get("/api/reports/jobs/{$job->id}/file?tenant={$this->tenant->id}&expires=9999999999&signature=deadbeef") ->assertStatus(403); }); test('download: просроченная подпись → 403', function () { $path = "reports/{$this->tenant->id}/2.csv"; Storage::disk('local')->put($path, 'data'); $job = doneReportJob($this->tenant->id, $this->user->id, ['file_path' => $path]); $this->get(signedDownloadUrl($job, Carbon::now()->subHour()))->assertStatus(403); }); test('download: file_path=NULL (истёк) → 410', function () { $job = doneReportJob($this->tenant->id, $this->user->id, ['file_path' => null]); $this->get(signedDownloadUrl($job))->assertStatus(410); }); test('download: файл отсутствует на диске → 404', function () { $job = doneReportJob($this->tenant->id, $this->user->id, ['file_path' => 'reports/missing/9.csv']); $this->get(signedDownloadUrl($job))->assertStatus(404); }); test('download: несуществующий job → 404', function () { $ghost = new ReportJob(['tenant_id' => $this->tenant->id]); $ghost->id = 999999; $this->get(signedDownloadUrl($ghost))->assertStatus(404); }); test('toResource (GET /jobs/{id}): done-job содержит download_url', function () { $path = "reports/{$this->tenant->id}/3.csv"; Storage::disk('local')->put($path, 'data'); $job = doneReportJob($this->tenant->id, $this->user->id, ['file_path' => $path]); $response = $this->actingAs($this->user)->getJson("/api/reports/jobs/{$job->id}"); $response->assertOk(); $url = $response->json('job.download_url'); expect($url)->toContain("/api/reports/jobs/{$job->id}/file"); expect($url)->toContain('signature='); }); test('toResource: pending-job → download_url = null', function () { $job = ReportJob::create([ 'tenant_id' => $this->tenant->id, 'user_id' => $this->user->id, 'type' => 'deals_export', 'parameters' => ['format' => 'csv', 'date_from' => '2026-04-01', 'date_to' => '2026-04-30'], 'status' => ReportJob::STATUS_PENDING, ]); $response = $this->actingAs($this->user)->getJson("/api/reports/jobs/{$job->id}"); expect($response->json('job.download_url'))->toBeNull(); }); test('download: expires_at в прошлом → 410 (до cron-очистки file_path)', function () { $path = "reports/{$this->tenant->id}/4.csv"; Storage::disk('local')->put($path, 'data'); $job = doneReportJob($this->tenant->id, $this->user->id, [ 'file_path' => $path, 'expires_at' => Carbon::now()->subDay(), ]); $this->get(signedDownloadUrl($job))->assertStatus(410); }); test('toResource: expired done-job → download_url = null', function () { $path = "reports/{$this->tenant->id}/5.csv"; Storage::disk('local')->put($path, 'data'); $job = doneReportJob($this->tenant->id, $this->user->id, [ 'file_path' => $path, 'expires_at' => Carbon::now()->subDay(), ]); $response = $this->actingAs($this->user)->getJson("/api/reports/jobs/{$job->id}"); expect($response->json('job.download_url'))->toBeNull(); }); test('download: подмена tenant в signed URL ломает подпись → 403', function () { $path = "reports/{$this->tenant->id}/6.csv"; Storage::disk('local')->put($path, 'data'); $job = doneReportJob($this->tenant->id, $this->user->id, ['file_path' => $path]); $url = signedDownloadUrl($job); $otherTenant = Tenant::factory()->create(); // Подменяем tenant= на tenant= в готовом signed URL — подпись // покрывает query-параметры, поэтому ValidateSignature вернёт 403. $tampered = str_replace( "tenant={$this->tenant->id}", "tenant={$otherTenant->id}", $url, ); $this->get($tampered)->assertStatus(403); });