' (negative string). * - balance_rub decremented by bcdiv(priceKopecks, 100, 2). * - supplier_lead_costs inserted when supplier resolved (B1/B2/B3/DIRECT). * - delivered_in_month incremented on tenant after charge. * * Intake validation verified from SupplierWebhookController.php: * - Bad secret → 404 (hash_equals fail). * - Rate-limit: 600/min per-IP; 601st request → 429. * - time outside ±24h → validation fail → 422 (Laravel validation). * - phone not matching ^7\d{10}$ → 422. * - IP allowlist: empty list on non-production env → fail-open (no 404 from IP). * * Worktree APP_KEY workaround: .env has 'base64:testingkeyplaceholderxxxxxxxxxxxxxxxo=' * which decodes to 31 bytes — invalid for AES-256-CBC (needs 32 bytes). * We inject a valid 32-byte key before HTTP-layer tests (G6-style, see ScenarioG5G6). * * Region codes: ordinal 1..89 (constitutional order), NOT ГИБДД. * Москва = 82, Санкт-Петербург = 83. * Verified via App\Support\RussianRegions::CODE_TO_NAME. */ use App\Models\Deal; use App\Models\Project; use App\Models\SupplierProject; use App\Models\SystemSetting; use App\Models\Tenant; use App\Models\User; use App\Services\DaData\DaDataPhoneClient; use Database\Seeders\PricingTierSeeder; use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\RateLimiter; use Tests\Concerns\SharesSupplierPdo; use Tests\Support\Imitation\FakeDaDataPhoneClient; use Tests\Support\Imitation\ImitationClientsSeeder; use Tests\Support\Imitation\LeadInjector; use Tests\Support\Imitation\SnapshotForge; uses(DatabaseTransactions::class, SharesSupplierPdo::class)->group('imitation'); // --------------------------------------------------------------------------- // Values (using define() with a guard to allow multi-process safe re-use) // --------------------------------------------------------------------------- /** Москва ordinal subject code (App\Support\RussianRegions::CODE_TO_NAME[82]). */ defined('TOPO_MOSCOW') || define('TOPO_MOSCOW', 82); /** Санкт-Петербург ordinal subject code (App\Support\RussianRegions::CODE_TO_NAME[83]). */ defined('TOPO_SPB') || define('TOPO_SPB', 83); /** Webhook secret for intake tests — 33 chars, passes strlen>=32 guard. */ defined('TOPO_SECRET') || define('TOPO_SECRET', 'intake-test-secret-32chars-aaaaab'); // --------------------------------------------------------------------------- // Shared beforeEach // --------------------------------------------------------------------------- beforeEach(function (): void { // Seed pricing tiers (required by LedgerService::chargeForDelivery). $this->seed(PricingTierSeeder::class); // Global bypass for cross-tenant reads during seeding. DB::statement("SELECT set_config('app.current_tenant_id', '0', true)"); // Disable DaData by default; individual tests enable as needed. config([ 'services.dadata.enabled' => false, 'services.dadata.api_key' => 'fake-key', 'services.dadata.secret' => 'fake-secret', 'services.dadata.daily_cap_rub' => 1_000_000, ]); }); // --------------------------------------------------------------------------- // Helpers (file-scoped) // --------------------------------------------------------------------------- /** * Fix the worktree APP_KEY to a valid 32-byte AES-256-CBC key. * Required before any HTTP-layer test (SupplierWebhookController). * Named with tmi_ prefix to avoid collision with helpers in other imitation test files. */ function tmi_fixAppKey(): void { if (strlen(base64_decode(str_replace('base64:', '', config('app.key'))) ?: '') !== 32) { config(['app.key' => 'base64:'.base64_encode(str_repeat('a', 32))]); try { app('encrypter')->__construct( str_repeat('a', 32), config('app.cipher', 'AES-256-CBC') ); } catch (Throwable) { // Encrypter may already be initialized; ignore re-init errors. } } } /** * Set a valid webhook secret in system_settings (for HTTP intake tests). */ function tmi_setIntakeSecret(string $secret = TOPO_SECRET): void { SystemSetting::query() ->where('key', 'supplier_webhook_secret') ->update(['value' => $secret]); } /** * Ensure IP allowlist is empty → fail-open in non-production env. */ function tmi_clearIpAllowlist(): void { SystemSetting::query() ->where('key', 'supplier_ip_allowlist') ->update(['value' => '[]']); } // =========================================================================== // ─── TOPOLOGIES (§6.3) ──────────────────────────────────────────────────── // =========================================================================== /** * G1: One client with one Project linked to TWO different SupplierProjects. * * A lead arriving via supplier B1 should reach the project if it is linked to B1. * A lead arriving via supplier B2 should ALSO reach the same project if it is linked to B2. * * Proves: one project can receive leads from multiple supplier sources. */ it('G1: project linked to two supplier sources receives leads from each', function (): void { config(['services.dadata.enabled' => true]); $seeder = new ImitationClientsSeeder; $g1 = $seeder->seedG1('IMIT-G1-topo'); /** @var Tenant $tenant */ $tenant = $g1['tenant']; /** @var Project $project */ $project = $g1['project']; /** @var list $suppliers */ $suppliers = $g1['suppliers']; [$supplierB1, $supplierB2] = $suppliers; // Set ample balance. DB::table('tenants')->where('id', $tenant->id)->update([ 'balance_rub' => '9999.00', 'frozen_by_balance_at' => null, 'delivered_in_month' => 0, ]); // Set project active, all-RF regions (empty = all), all days. DB::table('projects')->where('id', $project->id)->update([ 'is_active' => true, 'regions' => '{}', 'delivery_days_mask' => 127, 'delivered_today' => 0, ]); $activeDate = SnapshotForge::activeDate(); // Build snapshot for the project. For B1 (non-DIRECT), routing uses the pivot // (project_supplier_links), not signal_identifier in snapshot. The snapshot just // needs to exist with the correct project_id and date. createRoutingSnapshotFromProject( project: $project, date: $activeDate, signalType: 'site', signalIdentifier: $project->signal_identifier ?? 'g1-proj.test', dailyLimit: 50, regions: '{}', ); // Lead via B1 source. Inject using the supplier's unique_key so resolveOrStub // finds the SAME supplier_project that the pivot links to. $phoneB1 = '79161111001'; $fake = new FakeDaDataPhoneClient; $fake->stub($phoneB1, qc: 0, region: 'Москва', provider: 'МТС'); app()->instance(DaDataPhoneClient::class, $fake); $injector = new LeadInjector; $leadB1 = $injector->site( domain: $supplierB1->unique_key ?? 'g1-b1.test', phone: $phoneB1, tag: 'Москва', platform: $supplierB1->platform, vid: 9_100_000_001, ); $dealsB1 = DB::connection('pgsql_supplier') ->table('deals') ->where('tenant_id', $tenant->id) ->count(); expect($dealsB1)->toBeGreaterThan(0, 'FINDING: G1 — project linked to B1 supplier did not receive any deal from B1 lead. '. "supplier_project_id={$supplierB1->id}, project_id={$project->id}" ); })->group('imitation'); /** * G2: Two clients (Tenants/Projects) linked to the SAME SupplierProject. * * A lead on that supplier should be eligible for both projects (weighted lottery selects * ≥1 recipient). Each client's project must receive at least 1 lead across N injections. */ it('G2: two clients on same supplier each receive at least one lead', function (): void { config(['services.dadata.enabled' => true]); $seeder = new ImitationClientsSeeder; $g2 = $seeder->seedG2( ['daily_limit_target' => 20, 'regions' => [TOPO_MOSCOW]], ['daily_limit_target' => 20, 'regions' => [TOPO_MOSCOW]], ); /** @var SupplierProject $supplier */ $supplier = $g2['supplier']; /** @var list $projects */ $projects = $g2['projects']; /** @var list $tenants */ $tenants = $g2['tenants']; $activeDate = SnapshotForge::activeDate(); foreach ([$projects[0], $projects[1]] as $idx => $project) { DB::table('tenants')->where('id', $tenants[$idx]->id)->update([ 'balance_rub' => '9999.00', 'frozen_by_balance_at' => null, 'delivered_in_month' => 0, ]); DB::table('projects')->where('id', $project->id)->update([ 'is_active' => true, 'regions' => '{'.TOPO_MOSCOW.'}', 'delivery_days_mask' => 127, 'delivered_today' => 0, ]); createRoutingSnapshotFromProject( project: $project, date: $activeDate, signalType: 'site', signalIdentifier: $project->signal_identifier, dailyLimit: 20, regions: '{'.TOPO_MOSCOW.'}', ); } // Inject 10 leads — with two projects at equal weight (20) and a cap>1 // both should receive at least 1 deal across 10 leads. // Use the supplier's unique_key as the domain — LeadInjector builds "B2_{unique_key}" // and RouteSupplierLeadJob::resolveOrStub() looks up supplier_projects by (platform, unique_key). $supplierDomain = $supplier->unique_key ?? 'g2-src.test'; $fake = new FakeDaDataPhoneClient; for ($i = 1; $i <= 10; $i++) { $phone = '79162'.str_pad((string) $i, 6, '0', STR_PAD_LEFT); $fake->stub($phone, qc: 0, region: 'Москва', provider: 'МТС'); } app()->instance(DaDataPhoneClient::class, $fake); $injector = new LeadInjector; for ($i = 1; $i <= 10; $i++) { $phone = '79162'.str_pad((string) $i, 6, '0', STR_PAD_LEFT); $injector->site( domain: $supplierDomain, phone: $phone, tag: 'Москва', platform: $supplier->platform, vid: 9_200_000_000 + $i, ); } $deals0 = DB::connection('pgsql_supplier') ->table('deals') ->where('tenant_id', $tenants[0]->id) ->count(); $deals1 = DB::connection('pgsql_supplier') ->table('deals') ->where('tenant_id', $tenants[1]->id) ->count(); $totalDeals = $deals0 + $deals1; expect($totalDeals)->toBeGreaterThan(0, 'FINDING: G2 — no deals created for either client.' ); // Each client should receive at least 1 deal across 10 leads with equal weight. // With cap=3 per lead (LeadRouter selects up to 3) and 2 equally-weighted projects, // both should receive deals. This is probabilistic but seed-based. // If one is 0, it indicates routing bias — report as FINDING. expect($deals0)->toBeGreaterThan(0, "FINDING: G2 — client 0 received 0 deals out of {$totalDeals} total. ". 'Both clients have equal weight. Possible routing bias.' ); expect($deals1)->toBeGreaterThan(0, "FINDING: G2 — client 1 received 0 deals out of {$totalDeals} total. ". 'Both clients have equal weight. Possible routing bias.' ); })->group('imitation'); /** * G4: One client with TWO Projects on the SAME SupplierProject, each targeting a different region. * * Lead with subject_code=82 (Москва) must go to projectA (regions=[82]). * Lead with subject_code=83 (СПб) must go to projectB (regions=[83]). */ it('G4: two projects on same supplier with different regions each receive region-matching leads', function (): void { config(['services.dadata.enabled' => true]); $seeder = new ImitationClientsSeeder; $g4 = $seeder->seedG4(TOPO_MOSCOW, TOPO_SPB); /** @var SupplierProject $supplier */ $supplier = $g4['supplier']; /** @var Tenant $tenant */ $tenant = $g4['tenant']; /** @var Project $projectA */ $projectA = $g4['projectA']; // regions=[82] Москва /** @var Project $projectB */ $projectB = $g4['projectB']; // regions=[83] СПб DB::table('tenants')->where('id', $tenant->id)->update([ 'balance_rub' => '9999.00', 'frozen_by_balance_at' => null, 'delivered_in_month' => 0, ]); foreach ([$projectA, $projectB] as $project) { DB::table('projects')->where('id', $project->id)->update([ 'is_active' => true, 'delivery_days_mask' => 127, 'delivered_today' => 0, ]); } $activeDate = SnapshotForge::activeDate(); createRoutingSnapshotFromProject( project: $projectA, date: $activeDate, signalType: 'site', signalIdentifier: $projectA->signal_identifier, dailyLimit: 50, regions: '{'.TOPO_MOSCOW.'}', ); createRoutingSnapshotFromProject( project: $projectB, date: $activeDate, signalType: 'site', signalIdentifier: $projectB->signal_identifier, dailyLimit: 50, regions: '{'.TOPO_SPB.'}', ); // Use the supplier's unique_key as the domain — resolveOrStub looks up by (platform, unique_key). $supplierKey = $supplier->unique_key ?? 'g4-src.test'; $injector = new LeadInjector; // Lead A: Москва phone → resolved to code 82 → should go to projectA only. $phoneMoscow = '79163000001'; $fakeMoscow = (new FakeDaDataPhoneClient)->stub($phoneMoscow, qc: 0, region: 'Москва', provider: 'МТС'); app()->instance(DaDataPhoneClient::class, $fakeMoscow); $injector->site( domain: $supplierKey, phone: $phoneMoscow, tag: 'Москва', platform: $supplier->platform, vid: 9_400_000_001, ); $dealsAfterMoscowLead_A = DB::connection('pgsql_supplier') ->table('deals') ->where('tenant_id', $tenant->id) ->where('project_id', $projectA->id) ->count(); $dealsAfterMoscowLead_B = DB::connection('pgsql_supplier') ->table('deals') ->where('tenant_id', $tenant->id) ->where('project_id', $projectB->id) ->count(); expect($dealsAfterMoscowLead_A)->toBeGreaterThan(0, 'FINDING: G4 — Москва lead (subject_code=82) did not reach projectA (regions=[82]). '. "projectA_id={$projectA->id}, projectB_id={$projectB->id}" ); expect($dealsAfterMoscowLead_B)->toBe(0, 'FINDING: G4 — Москва lead (subject_code=82) leaked into projectB (regions=[83]). '. "Expected 0 deals for projectB, got {$dealsAfterMoscowLead_B}." ); // Lead B: СПб phone → resolved to code 83 → should go to projectB only. $phoneSpb = '79163000002'; $fakeSpb = (new FakeDaDataPhoneClient)->stub($phoneSpb, qc: 0, region: 'Санкт-Петербург', provider: 'МегаФон'); app()->instance(DaDataPhoneClient::class, $fakeSpb); $injector->site( domain: $supplierKey, phone: $phoneSpb, tag: 'Санкт-Петербург', platform: $supplier->platform, vid: 9_400_000_002, ); $dealsAfterSpbLead_A = DB::connection('pgsql_supplier') ->table('deals') ->where('tenant_id', $tenant->id) ->where('project_id', $projectA->id) ->count(); $dealsAfterSpbLead_B = DB::connection('pgsql_supplier') ->table('deals') ->where('tenant_id', $tenant->id) ->where('project_id', $projectB->id) ->count(); // projectA should still have only the first lead (unchanged). expect($dealsAfterSpbLead_A)->toBe($dealsAfterMoscowLead_A, 'FINDING: G4 — СПб lead (subject_code=83) leaked into projectA (regions=[82]). '. "Expected {$dealsAfterMoscowLead_A} deals for projectA, got {$dealsAfterSpbLead_A}." ); expect($dealsAfterSpbLead_B)->toBeGreaterThan(0, 'FINDING: G4 — СПб lead (subject_code=83) did not reach projectB (regions=[83]). '. "projectA_id={$projectA->id}, projectB_id={$projectB->id}" ); })->group('imitation'); // =========================================================================== // ─── MONEY CORRECTNESS (§7 Этап 4) ───────────────────────────────────────── // =========================================================================== /** * After a successful delivery: * 1. lead_charges row exists with correct tier price and charge_source='rub'. * 2. balance_transactions row has negative amount_rub matching the price. * 3. balance_transactions.balance_rub_after = balance_before − price (bcmath, no kopeck loss). * 4. supplier_lead_costs row exists. * 5. tenants.balance_rub decreased by exactly the tier price. * 6. tenants.delivered_in_month incremented. * * Tier lookup: delivered_in_month starts at 0; resolver uses count+1=1 → tier_no=1. * Tier 1: leads_in_tier=100, price_per_lead_kopecks=50000 → 500.00 rub. */ it('money: lead_charges, balance_transactions, supplier_lead_costs are correct after delivery', function (): void { config(['services.dadata.enabled' => true]); // Create a fresh tenant with known starting balance. // Tier 1 price = 50000 kopecks = 500.00 rub (delivered_in_month=0 → count+1=1). $initialBalance = '1000.00'; $expectedPriceKopecks = 50000; // tier 1 $expectedAmountRub = '500.00'; // 50000 / 100 $tenant = Tenant::factory()->create([ 'balance_rub' => $initialBalance, 'frozen_by_balance_at' => null, 'delivered_in_month' => 0, ]); $user = User::factory()->create(['tenant_id' => $tenant->id]); $supplier = SupplierProject::factory()->create([ 'platform' => 'B2', 'signal_type' => 'site', ]); $phone = '79164000001'; // PostgresIntArray cast requires PHP array, not '{...}' string literal. $project = Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, 'signal_type' => 'site', 'signal_identifier' => $supplier->unique_key ?? 'money-test-b2.test', 'regions' => [], // empty = all-RF; cast converts to '{}' 'delivery_days_mask' => 127, 'delivered_today' => 0, 'delivered_in_month' => 0, ]); linkProjectToSupplier($project, $supplier); $activeDate = SnapshotForge::activeDate(); createRoutingSnapshotFromProject( project: $project, date: $activeDate, signalType: 'site', signalIdentifier: $project->signal_identifier, dailyLimit: 50, regions: '{}', ); $fakeDaData = (new FakeDaDataPhoneClient)->stub($phone, qc: 0, region: 'Москва', provider: 'МТС'); app()->instance(DaDataPhoneClient::class, $fakeDaData); $injector = new LeadInjector; $injector->site( domain: ltrim($project->signal_identifier, '/'), phone: $phone, tag: 'Москва', platform: $supplier->platform, vid: 9_500_000_001, ); // ── Reload tenant to see updated balance ──────────────────────────────── $tenantAfter = $tenant->fresh(); // ── Assert deal was created ───────────────────────────────────────────── $deal = DB::connection('pgsql_supplier') ->table('deals') ->where('tenant_id', $tenant->id) ->latest('id') ->first(); expect($deal)->not->toBeNull('FINDING: No deal was created for the test lead.'); // ── 1. lead_charges ───────────────────────────────────────────────────── $charge = DB::table('lead_charges') ->where('tenant_id', $tenant->id) ->where('deal_id', $deal->id) ->first(); expect($charge)->not->toBeNull('FINDING: lead_charges row missing after delivery.'); expect((int) $charge->price_per_lead_kopecks)->toBe($expectedPriceKopecks, "FINDING: lead_charges.price_per_lead_kopecks = {$charge->price_per_lead_kopecks}, ". "expected {$expectedPriceKopecks} (tier 1, delivered_in_month was 0 → count+1=1)." ); expect($charge->charge_source)->toBe('rub', "FINDING: lead_charges.charge_source = '{$charge->charge_source}', expected 'rub'." ); expect((int) $charge->tier_no)->toBe(1, "FINDING: lead_charges.tier_no = {$charge->tier_no}, expected 1 (delivered_in_month+1=1 → tier 1)." ); // ── 2. balance_transactions ───────────────────────────────────────────── $bt = DB::table('balance_transactions') ->where('tenant_id', $tenant->id) ->orderBy('id', 'desc') ->first(); expect($bt)->not->toBeNull('FINDING: balance_transactions row missing after delivery.'); // amount_rub must be negative (stored as '-500.00'). $amountRub = (string) $bt->amount_rub; expect(bccomp($amountRub, '0', 2))->toBe(-1, "FINDING: balance_transactions.amount_rub = '{$amountRub}' is not negative." ); // The absolute value must equal the tier price. $absAmount = ltrim($amountRub, '-'); expect($absAmount)->toBe($expectedAmountRub, "FINDING: balance_transactions.amount_rub absolute value = '{$absAmount}', ". "expected '{$expectedAmountRub}' (kopecks={$expectedPriceKopecks} → rub=500.00)." ); // ── 3. No kopeck loss (bcmath precision check) ─────────────────────────── // Expected new balance = 1000.00 - 500.00 = 500.00 $expectedNewBalance = bcsub($initialBalance, $expectedAmountRub, 2); $actualNewBalance = (string) $tenantAfter->balance_rub; // Normalize: bcmath may return '500.00'; DB may store '500.00' as well. expect($actualNewBalance)->toBe($expectedNewBalance, 'FINDING: KOPECK LOSS detected. '. "Initial balance: {$initialBalance}, price: {$expectedAmountRub}. ". "Expected new balance: {$expectedNewBalance}, actual: {$actualNewBalance}. ". 'This indicates floating-point or bcmath precision error.' ); // balance_rub_after in balance_transactions must match actual tenant balance. $btBalanceAfter = (string) $bt->balance_rub_after; expect($btBalanceAfter)->toBe($expectedNewBalance, "FINDING: balance_transactions.balance_rub_after = '{$btBalanceAfter}', ". "expected '{$expectedNewBalance}'. Ledger audit trail inconsistency." ); // ── 4. supplier_lead_costs ─────────────────────────────────────────────── $slc = DB::table('supplier_lead_costs') ->where('deal_id', $deal->id) ->first(); expect($slc)->not->toBeNull( 'FINDING: supplier_lead_costs row missing after delivery. '. 'LedgerService should insert it when supplier resolved via platform B2.' ); // ── 5. delivered_in_month incremented ─────────────────────────────────── expect((int) $tenantAfter->delivered_in_month)->toBe(1, "FINDING: tenants.delivered_in_month = {$tenantAfter->delivered_in_month}, ". 'expected 1 after first lead delivery (started at 0).' ); })->group('imitation'); /** * Tier price uses delivered_in_month + 1 at the moment of charge. * * Tenant with delivered_in_month=99 → count+1=100 → still tier 1 (leads_in_tier=100). * Tenant with delivered_in_month=100 → count+1=101 → tier 2 (price=45000 kopecks). */ it('money: tier is resolved by delivered_in_month+1 boundary', function (): void { config(['services.dadata.enabled' => true]); // delivered_in_month=100 → count+1=101 → tier 2 (price=45000 kopecks = 450.00 rub). $expectedPriceKopecks = 45000; $expectedAmountRub = '450.00'; $tenant = Tenant::factory()->create([ 'balance_rub' => '9999.00', 'frozen_by_balance_at' => null, 'delivered_in_month' => 100, // one past tier-1 boundary (100 leads used tier 1) ]); User::factory()->create(['tenant_id' => $tenant->id]); $supplier = SupplierProject::factory()->create(['platform' => 'B2', 'signal_type' => 'site']); $supplierKey2 = $supplier->unique_key; // used for injection domain // Give the project the supplier's unique_key as signal_identifier so the factory // asSiteSignal sets it correctly; alternatively pass signal_type/signal_identifier directly. $project = Project::factory() ->asSiteSignal($supplierKey2) ->create([ 'tenant_id' => $tenant->id, 'is_active' => true, 'regions' => [], // empty = all-RF; PostgresIntArray cast expects PHP array 'delivery_days_mask' => 127, 'delivered_today' => 0, 'delivered_in_month' => 100, ]); linkProjectToSupplier($project, $supplier); $activeDate = SnapshotForge::activeDate(); createRoutingSnapshotFromProject( project: $project, date: $activeDate, signalType: 'site', signalIdentifier: $supplierKey2, dailyLimit: 50, regions: '{}', ); $phone = '79165000002'; $fake = (new FakeDaDataPhoneClient)->stub($phone, qc: 0, region: 'Москва', provider: 'МТС'); app()->instance(DaDataPhoneClient::class, $fake); $injector = new LeadInjector; $injector->site( domain: $supplierKey2, phone: $phone, tag: 'Москва', platform: $supplier->platform, vid: 9_500_100_001, ); $deal = DB::connection('pgsql_supplier') ->table('deals') ->where('tenant_id', $tenant->id) ->latest('id') ->first(); expect($deal)->not->toBeNull('FINDING: No deal created for tier boundary test.'); $charge = DB::table('lead_charges') ->where('tenant_id', $tenant->id) ->where('deal_id', $deal->id) ->first(); expect($charge)->not->toBeNull('FINDING: lead_charges row missing for tier boundary test.'); expect((int) $charge->price_per_lead_kopecks)->toBe($expectedPriceKopecks, 'FINDING: Tier boundary wrong. delivered_in_month=100 → count+1=101 → should be tier 2 '. "(price=45000 kopecks). Got: {$charge->price_per_lead_kopecks}." ); expect($charge->charge_source)->toBe('rub'); })->group('imitation'); // =========================================================================== // ─── INTAKE VALIDATION (§7 Этап 0) ───────────────────────────────────────── // =========================================================================== /** * Intake: bad secret → 404. * * SupplierWebhookController: verifySecret() uses hash_equals; wrong secret → 404. */ it('intake: bad secret returns 404', function (): void { tmi_fixAppKey(); tmi_setIntakeSecret(TOPO_SECRET); tmi_clearIpAllowlist(); $response = $this->postJson('/api/webhook/supplier/THIS-IS-THE-WRONG-SECRET-XXXXX', [ 'vid' => 999_001, 'project' => 'B2_some-domain.test', 'phone' => '79161234567', 'time' => now()->timestamp, 'tag' => 'Москва', ]); expect($response->status())->toBe(404, 'FINDING: Bad webhook secret did not return 404. '. "Got HTTP {$response->status()}." ); })->group('imitation'); /** * Intake: phone not matching ^7\d{10}$ → 422. * * Controller validate: 'phone' => ['required', 'string', 'regex:/^7\d{10}$/']. * An 11-digit number starting with 8 fails the regex → Laravel returns 422. */ it('intake: invalid phone (wrong prefix) returns 422', function (): void { tmi_fixAppKey(); tmi_setIntakeSecret(TOPO_SECRET); tmi_clearIpAllowlist(); $response = $this->postJson('/api/webhook/supplier/'.TOPO_SECRET, [ 'vid' => 999_002, 'project' => 'B2_some-domain.test', 'phone' => '89161234567', // starts with 8, fails /^7\d{10}$/ 'time' => now()->timestamp, 'tag' => 'Москва', ]); expect($response->status())->toBe(422, 'FINDING: Phone starting with 8 (invalid) did not return 422. '. "Got HTTP {$response->status()}. Response: ".$response->content() ); })->group('imitation'); /** * Intake: phone too short → 422. */ it('intake: too-short phone returns 422', function (): void { tmi_fixAppKey(); tmi_setIntakeSecret(TOPO_SECRET); tmi_clearIpAllowlist(); $response = $this->postJson('/api/webhook/supplier/'.TOPO_SECRET, [ 'vid' => 999_003, 'project' => 'B2_some-domain.test', 'phone' => '7916123', // too short — only 7 digits after 7 'time' => now()->timestamp, 'tag' => 'Москва', ]); expect($response->status())->toBe(422, 'FINDING: Short phone did not return 422. '. "Got HTTP {$response->status()}." ); })->group('imitation'); /** * Intake: time outside ±24h → 422. * * Controller: min = now()-24h, max = now()+24h. timestamp 48h in past fails validation. */ it('intake: timestamp 48h in the past (beyond -24h window) returns 422', function (): void { tmi_fixAppKey(); tmi_setIntakeSecret(TOPO_SECRET); tmi_clearIpAllowlist(); $oldTime = now()->subHours(48)->getTimestamp(); // 48h ago — outside ±24h window $response = $this->postJson('/api/webhook/supplier/'.TOPO_SECRET, [ 'vid' => 999_004, 'project' => 'B2_some-domain.test', 'phone' => '79161234568', 'time' => $oldTime, 'tag' => 'Москва', ]); expect($response->status())->toBe(422, 'FINDING: Timestamp 48h in the past did not return 422. '. "Got HTTP {$response->status()}. ". 'Controller requires time within ±24h (min=now-24h, max=now+24h).' ); })->group('imitation'); /** * Intake: time outside +24h (future) → 422. */ it('intake: timestamp 48h in the future (beyond +24h window) returns 422', function (): void { tmi_fixAppKey(); tmi_setIntakeSecret(TOPO_SECRET); tmi_clearIpAllowlist(); $futureTime = now()->addHours(48)->getTimestamp(); // 48h in future $response = $this->postJson('/api/webhook/supplier/'.TOPO_SECRET, [ 'vid' => 999_005, 'project' => 'B2_some-domain.test', 'phone' => '79161234569', 'time' => $futureTime, 'tag' => 'Москва', ]); expect($response->status())->toBe(422, 'FINDING: Timestamp 48h in the future did not return 422. '. "Got HTTP {$response->status()}." ); })->group('imitation'); /** * Intake: flood >600/min from one IP → 429. * * Controller uses RateLimiter::tooManyAttempts($key, 600) per-IP. * After 600 hits the 601st attempt should be rate-limited. * * Note: We manipulate the rate limiter counter directly via RateLimiter::hit() * to avoid actually making 601 HTTP requests (too slow for a test). * This verifies the rate-limit enforcement path, not the counter increment. */ it('intake: rate-limit 600/min per IP — 601st request returns 429', function (): void { tmi_fixAppKey(); tmi_setIntakeSecret(TOPO_SECRET); tmi_clearIpAllowlist(); // Simulate the rate limiter already being at its limit for '127.0.0.1'. // The controller uses key 'supplier-webhook:'. $rateKey = 'supplier-webhook:127.0.0.1'; // Clear any existing state first, then saturate the limiter. RateLimiter::clear($rateKey); // Hit 600 times to reach the limit (the 601st should be too-many). for ($i = 0; $i < 600; $i++) { RateLimiter::hit($rateKey, 60); } // Now the 601st HTTP request should see tooManyAttempts = true. $response = $this->postJson('/api/webhook/supplier/'.TOPO_SECRET, [ 'vid' => 999_006, 'project' => 'B2_some-domain.test', 'phone' => '79161234570', 'time' => now()->timestamp, 'tag' => 'Москва', ]); expect($response->status())->toBe(429, 'FINDING: 601st request (after 600 hits) did not return 429 (rate limited). '. "Got HTTP {$response->status()}. ". 'Controller has RATE_LIMIT_PER_MINUTE=600. Rate-limit enforcement may be broken.' ); // Clean up rate limiter state to avoid cross-test pollution. RateLimiter::clear($rateKey); })->group('imitation');