fix(http): timestamp validation ±24h для partition guard (Plan 2.6 #iii)

Закрывает CV.11 audit WARN minor #5 (Carbon::createFromTimestamp(time) без
range guard → INSERT CRASH "no partition of relation deals found for row"
для timestamp вне текущего месячного окна deals_2026_MM).

Изменение: SupplierWebhookController::receive — добавлено min/max constraint
на 'time' = [now-24h, now+24h] unix-timestamp. Timestamp вне окна → 422
ValidationException.

±24h: покрывает retry-задержки поставщика (network-сбой) + clock-drift серверов;
шире окно (±48h+) = риск partition-промаха на стыке месяцев (нужен Plan 5
partition cron).

TDD: +3 теста (-2 days → 422; +2 days → 422; -6h → 202).

Regression-fix: existing test 'inserts supplier_lead row' использовал hardcoded
'time' => 1703781939 (Dec 28 2023) — теперь out-of-window. Заменено на time().

phpstan-baseline: postJson() count: 8 → 11 (+3 от Task 3 тестов).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-05-10 23:11:26 +03:00
parent f78a85595c
commit 451a2944f7
3 changed files with 46 additions and 3 deletions
@@ -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}$/',
+1 -1
View File
@@ -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
-
@@ -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);
});