$tenantId, 'user_id' => $userId, 'name' => 'test', 'key_hash' => Hash::make($plain), 'key_prefix' => substr($plain, 0, 10), 'scopes' => ['read'], 'expires_at' => now()->addYear(), 'is_active' => true, 'created_at' => now(), ], $over)); return $plain; } test('валидный ключ → 200 и только свои сделки', function () { $tenantA = Tenant::factory()->create(); $userA = User::factory()->create(['tenant_id' => $tenantA->id]); $tenantB = Tenant::factory()->create(); Deal::factory()->count(2)->create(['tenant_id' => $tenantA->id, 'received_at' => now()]); Deal::factory()->create(['tenant_id' => $tenantB->id, 'received_at' => now()]); $key = makeApiKey($tenantA->id, $userA->id); $r = $this->getJson('/api/v1/deals', ['Authorization' => "Bearer {$key}"]); $r->assertOk(); expect($r->json('data'))->toHaveCount(2); }); test('нет заголовка → 401', function () { $this->getJson('/api/v1/deals')->assertStatus(401); }); test('неверный ключ → 401', function () { $tenant = Tenant::factory()->create(); $user = User::factory()->create(['tenant_id' => $tenant->id]); $key = makeApiKey($tenant->id, $user->id); // Тот же префикс, но изменённый хвост — Hash::check не пройдёт. $bad = substr($key, 0, 10).str_repeat('x', strlen($key) - 10); $this->getJson('/api/v1/deals', ['Authorization' => "Bearer {$bad}"])->assertStatus(401); }); test('просроченный ключ → 401', function () { $tenant = Tenant::factory()->create(); $user = User::factory()->create(['tenant_id' => $tenant->id]); $key = makeApiKey($tenant->id, $user->id, ['expires_at' => now()->subDay()]); $this->getJson('/api/v1/deals', ['Authorization' => "Bearer {$key}"])->assertStatus(401); }); test('неактивный ключ → 401', function () { $tenant = Tenant::factory()->create(); $user = User::factory()->create(['tenant_id' => $tenant->id]); $key = makeApiKey($tenant->id, $user->id, ['is_active' => false]); $this->getJson('/api/v1/deals', ['Authorization' => "Bearer {$key}"])->assertStatus(401); }); test('last_used_at обновляется после успешного запроса', function () { $tenant = Tenant::factory()->create(); $user = User::factory()->create(['tenant_id' => $tenant->id]); $key = makeApiKey($tenant->id, $user->id, ['last_used_at' => null]); $this->getJson('/api/v1/deals', ['Authorization' => "Bearer {$key}"])->assertOk(); $row = ApiKey::where('tenant_id', $tenant->id)->latest('id')->first(); expect($row->last_used_at)->not->toBeNull(); }); test('route-throttle: 121-й запрос к /api/v1/deals с одного источника → 429', function () { // apiv1-rate (приёмка 21.06): публичный read-API без лимита — bcrypt/DB на // каждый запрос (brute/DoS-поверхность). Лимит 120/мин на источник (ключ→IP). // Бьём безключевыми запросами: throttle стоит ДО apikey, поэтому 1..120 → 401 // (от apikey, за throttle), 121-й → 429 (от throttle-middleware, до apikey). // Это доказывает: лимит навешен на маршрут именно route-throttle'ом. for ($i = 1; $i <= 120; $i++) { $this->getJson('/api/v1/deals')->assertStatus(401); } $this->getJson('/api/v1/deals')->assertStatus(429); }); test('since-фильтр отсекает старые сделки', function () { $tenant = Tenant::factory()->create(); $user = User::factory()->create(['tenant_id' => $tenant->id]); Deal::factory()->create(['tenant_id' => $tenant->id, 'received_at' => now()]); Deal::factory()->create(['tenant_id' => $tenant->id, 'received_at' => now()->subDays(10)]); $key = makeApiKey($tenant->id, $user->id); $since = now()->subDays(2)->toDateString(); $r = $this->getJson("/api/v1/deals?since={$since}", ['Authorization' => "Bearer {$key}"]); $r->assertOk(); expect($r->json('data'))->toHaveCount(1); }); test('невалидный since → 422', function () { $tenant = Tenant::factory()->create(); $user = User::factory()->create(['tenant_id' => $tenant->id]); $key = makeApiKey($tenant->id, $user->id); $this->getJson('/api/v1/deals?since=не-дата-вовсе', ['Authorization' => "Bearer {$key}"]) ->assertStatus(422); }); test('невалидный cursor → 422', function () { $tenant = Tenant::factory()->create(); $user = User::factory()->create(['tenant_id' => $tenant->id]); $key = makeApiKey($tenant->id, $user->id); // Валидный base64, но не JSON с ключами r/i → 422. $bad = base64_encode('просто строка'); $this->getJson("/api/v1/deals?cursor={$bad}", ['Authorization' => "Bearer {$key}"]) ->assertStatus(422); }); test('ключ без scope read → 403', function () { $tenant = Tenant::factory()->create(); $user = User::factory()->create(['tenant_id' => $tenant->id]); $key = makeApiKey($tenant->id, $user->id, ['scopes' => ['write']]); $this->getJson('/api/v1/deals', ['Authorization' => "Bearer {$key}"]) ->assertStatus(403); }); test('keyset-пагинация: limit=1 → next_cursor → вторая страница без перекрытия', function () { $tenant = Tenant::factory()->create(); $user = User::factory()->create(['tenant_id' => $tenant->id]); Deal::factory()->create(['tenant_id' => $tenant->id, 'received_at' => now()->subMinutes(1)]); Deal::factory()->create(['tenant_id' => $tenant->id, 'received_at' => now()->subMinutes(2)]); Deal::factory()->create(['tenant_id' => $tenant->id, 'received_at' => now()->subMinutes(3)]); $key = makeApiKey($tenant->id, $user->id); $auth = ['Authorization' => "Bearer {$key}"]; $p1 = $this->getJson('/api/v1/deals?limit=1', $auth); $p1->assertOk(); expect($p1->json('data'))->toHaveCount(1); $cursor = $p1->json('next_cursor'); expect($cursor)->not->toBeNull(); $firstId = $p1->json('data.0.id'); $p2 = $this->getJson("/api/v1/deals?limit=1&cursor={$cursor}", $auth); $p2->assertOk(); expect($p2->json('data'))->toHaveCount(1); // Вторая страница не повторяет первую (keyset, не offset). expect($p2->json('data.0.id'))->not->toBe($firstId); });