fb4e711b4a
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).
240 lines
9.7 KiB
PHP
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'); // НЕ затронут
|
|
});
|