2026-05-16 12:36:08 +03:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
use App\Models\ReportJob;
|
|
|
|
|
use App\Models\Tenant;
|
|
|
|
|
use App\Models\User;
|
|
|
|
|
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
|
|
|
|
use Illuminate\Support\Carbon;
|
|
|
|
|
use Illuminate\Support\Facades\Storage;
|
|
|
|
|
use Illuminate\Support\Facades\URL;
|
|
|
|
|
|
|
|
|
|
uses(DatabaseTransactions::class);
|
|
|
|
|
|
|
|
|
|
beforeEach(function () {
|
|
|
|
|
$this->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();
|
|
|
|
|
});
|
2026-05-16 12:42:00 +03:00
|
|
|
|
|
|
|
|
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=<own> на tenant=<other> в готовом signed URL — подпись
|
|
|
|
|
// покрывает query-параметры, поэтому ValidateSignature вернёт 403.
|
|
|
|
|
$tampered = str_replace(
|
|
|
|
|
"tenant={$this->tenant->id}",
|
|
|
|
|
"tenant={$otherTenant->id}",
|
|
|
|
|
$url,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
$this->get($tampered)->assertStatus(403);
|
|
|
|
|
});
|