diff --git a/app/app/Http/Controllers/Api/SupplierWebhookController.php b/app/app/Http/Controllers/Api/SupplierWebhookController.php index e6c8e660..4e5e0732 100644 --- a/app/app/Http/Controllers/Api/SupplierWebhookController.php +++ b/app/app/Http/Controllers/Api/SupplierWebhookController.php @@ -50,11 +50,19 @@ class SupplierWebhookController extends Controller return response()->json(['message' => 'Not found.'], 404); } + // Plan 2.6 fix #iii: timestamp partition guard. Партиции deals месячные + // (deals_2026_MM); time за пределами текущего месяца → INSERT CRASH + // "no partition of relation deals found for row" в RouteSupplierLeadJob. + // Окно ±24h защищает от wildly out-of-range значений (старый/будущий + // дроп от поставщика); покрывает retry-задержки + clock-drift серверов. + $minTime = now()->subDay()->getTimestamp(); + $maxTime = now()->addDay()->getTimestamp(); + $validated = $request->validate([ 'vid' => 'required|integer|min:1', 'project' => ['required', 'string', 'max:255', 'regex:/^B[123]_.+$/'], 'phone' => ['required', 'string', 'regex:/^7\d{10}$/'], - 'time' => 'required|integer|min:1', + 'time' => ['required', 'integer', "min:{$minTime}", "max:{$maxTime}"], 'tag' => 'nullable|string|max:255', 'phones' => 'nullable|array', 'phones.*' => 'string|regex:/^7\d{10}$/', diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index f238caf2..443856a3 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -597,7 +597,7 @@ parameters: - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#' identifier: method.notFound - count: 8 + count: 11 path: tests/Feature/Http/Webhook/SupplierWebhookTest.php - diff --git a/app/tests/Feature/Http/Webhook/SupplierWebhookTest.php b/app/tests/Feature/Http/Webhook/SupplierWebhookTest.php index 35532c29..ec94a041 100644 --- a/app/tests/Feature/Http/Webhook/SupplierWebhookTest.php +++ b/app/tests/Feature/Http/Webhook/SupplierWebhookTest.php @@ -54,7 +54,7 @@ it('inserts supplier_lead row + dispatches RouteSupplierLeadJob', function () { 'tag' => 'Ваш инвестор', 'phone' => '79991234567', 'phones' => ['79991234567'], - 'time' => 1703781939, + 'time' => time(), ]); $response->assertStatus(202); @@ -122,3 +122,38 @@ it('allows empty IP allowlist в testing env (Plan 2.6 fix #ii — fail-open д $response->assertStatus(202); }); + +it('rejects timestamp older than 24h (Plan 2.6 fix #iii — partition guard)', function () { + $response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [ + 'vid' => 100001, + 'project' => 'B1_old-time.ru', + 'phone' => '79991234567', + 'time' => now()->subDays(2)->getTimestamp(), + ]); + + $response->assertStatus(422)->assertJsonValidationErrors('time'); +}); + +it('rejects timestamp more than 24h in future (Plan 2.6 fix #iii — partition guard)', function () { + $response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [ + 'vid' => 100002, + 'project' => 'B1_future-time.ru', + 'phone' => '79991234567', + 'time' => now()->addDays(2)->getTimestamp(), + ]); + + $response->assertStatus(422)->assertJsonValidationErrors('time'); +}); + +it('accepts timestamp within ±24h window (Plan 2.6 fix #iii — partition guard)', function () { + Bus::fake(); + + $response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [ + 'vid' => 100003, + 'project' => 'B1_valid-time.ru', + 'phone' => '79991234567', + 'time' => now()->subHours(6)->getTimestamp(), + ]); + + $response->assertStatus(202); +});