Files
portal/app/tests/Feature/Reports/ReportLifecycleTest.php
T
Дмитрий fb4e711b4a fix(rls): close 4 dev↔prod RLS gaps in cron/jobs (hole #7 Phase B)
Found by docs/audit/2026-05-23-rls-gap-audit.md. Each touched an RLS-protected
table on the default connection in cron/queue context (no tenant GUC) — crash or
silent misbehaviour on prod (crm_app_user, not BYPASSRLS), hidden on dev (superuser).

- RemindersDispatchDue (Pattern B): gather pending via pgsql_supplier, then
  per-reminder DB::transaction + SET LOCAL app.current_tenant_id (isolation kept).
- ReportsCleanupExpired (Pattern A): SaaS-admin cron → report_jobs + pd_processing_log
  via pgsql_supplier (BYPASSRLS).
- GenerateReportJob (Pattern B): +readonly int $tenantId ctor param, wrap handle()
  in DB::transaction + SET LOCAL; both ReportJobController dispatch sites updated.
- ProcessWebhookJob::failed (Pattern A): failed_webhook_jobs insert via pgsql_supplier
  → webhook failures now logged, incidents:watch-failures can see them.

Tests +SharesSupplierPdo trait. 118 passed / 0 failed. My 5 src files pass larastan
isolated (0 errors).
2026-05-23 10:16:46 +03:00

240 lines
9.7 KiB
PHP

<?php
declare(strict_types=1);
use App\Jobs\GenerateReportJob;
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\Bus;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
beforeEach(function () {
$this->tenant = Tenant::factory()->create();
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
$this->actingAs($this->user);
Storage::fake('local');
});
function makeJob(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_PENDING,
], $overrides));
}
// ------ retry ------
test('POST /retry: 404 unknown', function () {
$this->postJson('/api/reports/jobs/999999/retry')->assertStatus(404);
});
test('POST /retry: 404 чужой', function () {
$other = Tenant::factory()->create();
$otherUser = User::factory()->create(['tenant_id' => $other->id]);
$job = makeJob($other->id, $otherUser->id, ['status' => ReportJob::STATUS_FAILED]);
$this->postJson("/api/reports/jobs/{$job->id}/retry")->assertStatus(404);
});
test('POST /retry: 403 не владелец (тот же tenant)', function () {
$other = User::factory()->create(['tenant_id' => $this->tenant->id]);
$job = makeJob($this->tenant->id, $other->id, ['status' => ReportJob::STATUS_FAILED]);
$this->postJson("/api/reports/jobs/{$job->id}/retry")->assertStatus(403);
});
test('POST /retry: 422 если status != failed', function () {
$job = makeJob($this->tenant->id, $this->user->id, ['status' => ReportJob::STATUS_DONE]);
$response = $this->postJson("/api/reports/jobs/{$job->id}/retry");
$response->assertStatus(422);
expect($response->json('errors.status.0'))->toContain('done');
});
test('POST /retry: success — создаёт NEW job + dispatch + retry_count=1', function () {
Bus::fake();
$original = makeJob($this->tenant->id, $this->user->id, ['status' => ReportJob::STATUS_FAILED]);
$response = $this->postJson("/api/reports/jobs/{$original->id}/retry");
$response->assertStatus(201);
expect($response->json('job.id'))->not->toBe($original->id);
expect($response->json('job.status'))->toBe('pending');
expect($response->json('job.retry_count'))->toBe(1);
expect($response->json('job.parameters.retry_of'))->toBe($original->id);
Bus::assertDispatched(GenerateReportJob::class);
});
test('POST /retry: 422 после 3 попыток (CTO-6)', function () {
Bus::fake();
$job = makeJob($this->tenant->id, $this->user->id, [
'status' => ReportJob::STATUS_FAILED,
'parameters' => ['format' => 'csv', 'date_from' => '2026-04-01', 'date_to' => '2026-04-30', 'retry_count' => 2],
]);
$response = $this->postJson("/api/reports/jobs/{$job->id}/retry");
$response->assertStatus(422);
expect($response->json('errors._retry.0'))->toContain('Максимум 3');
});
test('POST /retry: 422 если старше 7 дней', function () {
Bus::fake();
$job = makeJob($this->tenant->id, $this->user->id, ['status' => ReportJob::STATUS_FAILED]);
DB::table('report_jobs')->where('id', $job->id)->update(['created_at' => Carbon::now()->subDays(8)]);
$response = $this->postJson("/api/reports/jobs/{$job->id}/retry");
$response->assertStatus(422);
expect($response->json('errors._retry.0'))->toContain('7 дней');
});
test('POST /retry: 422 если квота 3 уже исчерпана', function () {
Bus::fake();
$failed = makeJob($this->tenant->id, $this->user->id, ['status' => ReportJob::STATUS_FAILED]);
foreach (range(1, 3) as $_) {
makeJob($this->tenant->id, $this->user->id);
}
$response = $this->postJson("/api/reports/jobs/{$failed->id}/retry");
$response->assertStatus(422);
expect($response->json('errors._quota.0'))->toContain('Максимум 3');
});
// ------ cancel ------
test('POST /cancel: 404 unknown', function () {
$this->postJson('/api/reports/jobs/999999/cancel')->assertStatus(404);
});
test('POST /cancel: 422 если не pending', function () {
$job = makeJob($this->tenant->id, $this->user->id, ['status' => ReportJob::STATUS_DONE]);
$response = $this->postJson("/api/reports/jobs/{$job->id}/cancel");
$response->assertStatus(422);
});
test('POST /cancel: success → status=failed + Отменено пользователем', function () {
$job = makeJob($this->tenant->id, $this->user->id);
$response = $this->postJson("/api/reports/jobs/{$job->id}/cancel");
$response->assertStatus(200);
expect($response->json('job.status'))->toBe('failed');
expect($response->json('job.error_message'))->toBe('Отменено пользователем.');
expect($response->json('job.finished_at'))->not->toBeNull();
});
test('POST /cancel: 403 не владелец', function () {
$other = User::factory()->create(['tenant_id' => $this->tenant->id]);
$job = makeJob($this->tenant->id, $other->id);
$this->postJson("/api/reports/jobs/{$job->id}/cancel")->assertStatus(403);
});
// ------ destroy ------
test('DELETE /jobs/{id}: 404 unknown', function () {
$this->deleteJson('/api/reports/jobs/999999')->assertStatus(404);
});
test('DELETE /jobs/{id}: 422 если pending (не terminal)', function () {
$job = makeJob($this->tenant->id, $this->user->id);
$this->deleteJson("/api/reports/jobs/{$job->id}")->assertStatus(422);
});
test('DELETE /jobs/{id}: 403 не владелец', function () {
$other = User::factory()->create(['tenant_id' => $this->tenant->id]);
$job = makeJob($this->tenant->id, $other->id, ['status' => ReportJob::STATUS_DONE]);
$this->deleteJson("/api/reports/jobs/{$job->id}")->assertStatus(403);
});
test('DELETE /jobs/{id}: success → row + file deleted', function () {
Storage::disk('local')->put('reports/test/123.csv', 'data');
$job = makeJob($this->tenant->id, $this->user->id, [
'status' => ReportJob::STATUS_DONE, 'file_path' => 'reports/test/123.csv',
]);
$this->deleteJson("/api/reports/jobs/{$job->id}")->assertStatus(200);
Storage::disk('local')->assertMissing('reports/test/123.csv');
expect(ReportJob::find($job->id))->toBeNull();
});
test('DELETE /jobs/{id}: success даже если file_path=NULL (expired)', function () {
$job = makeJob($this->tenant->id, $this->user->id, [
'status' => ReportJob::STATUS_DONE, 'file_path' => null,
]);
$this->deleteJson("/api/reports/jobs/{$job->id}")->assertStatus(200);
expect(ReportJob::find($job->id))->toBeNull();
});
// ------ index includes is_expired + retry_count ------
test('GET /jobs: response содержит is_expired + retry_count + retry_max', function () {
makeJob($this->tenant->id, $this->user->id, [
'status' => ReportJob::STATUS_DONE,
'expires_at' => Carbon::now()->subDay(), // expired
'parameters' => ['format' => 'csv', 'date_from' => '2026-04-01', 'date_to' => '2026-04-30', 'retry_count' => 2],
]);
$response = $this->getJson('/api/reports/jobs');
expect($response->json('jobs.0.is_expired'))->toBeTrue();
expect($response->json('jobs.0.retry_count'))->toBe(2);
expect($response->json('jobs.0.retry_max'))->toBe(3);
});
// ------ cleanup-expired cron ------
test('reports:cleanup-expired: удаляет file_path expired done-jobs', function () {
Storage::disk('local')->put('reports/exp/1.csv', 'a');
Storage::disk('local')->put('reports/exp/2.csv', 'b');
$expired = makeJob($this->tenant->id, $this->user->id, [
'status' => ReportJob::STATUS_DONE,
'file_path' => 'reports/exp/1.csv',
'expires_at' => Carbon::now()->subDay(),
]);
$alive = makeJob($this->tenant->id, $this->user->id, [
'status' => ReportJob::STATUS_DONE,
'file_path' => 'reports/exp/2.csv',
'expires_at' => Carbon::now()->addDay(),
]);
$this->artisan('reports:cleanup-expired')->assertExitCode(0);
Storage::disk('local')->assertMissing('reports/exp/1.csv');
Storage::disk('local')->assertExists('reports/exp/2.csv');
expect(ReportJob::find($expired->id)->file_path)->toBeNull();
expect(ReportJob::find($expired->id)->status)->toBe('done'); // CTO-10: статус НЕ меняется
expect(ReportJob::find($alive->id)->file_path)->toBe('reports/exp/2.csv');
});
test('reports:cleanup-expired --dry-run: ничего не удаляет', function () {
Storage::disk('local')->put('reports/exp/3.csv', 'c');
$job = makeJob($this->tenant->id, $this->user->id, [
'status' => ReportJob::STATUS_DONE,
'file_path' => 'reports/exp/3.csv',
'expires_at' => Carbon::now()->subDay(),
]);
$this->artisan('reports:cleanup-expired --dry-run')->assertExitCode(0);
Storage::disk('local')->assertExists('reports/exp/3.csv');
expect(ReportJob::find($job->id)->file_path)->toBe('reports/exp/3.csv');
});
test('reports:cleanup-expired: failed-jobs игнорируются (даже если expired)', function () {
$job = makeJob($this->tenant->id, $this->user->id, [
'status' => ReportJob::STATUS_FAILED,
'file_path' => 'reports/x/1.csv',
'expires_at' => Carbon::now()->subDay(),
]);
$this->artisan('reports:cleanup-expired')->assertExitCode(0);
expect(ReportJob::find($job->id)->file_path)->toBe('reports/x/1.csv'); // НЕ затронут
});