Files
portal/app/tests/Feature/Api/V1/PublicDealsApiTest.php
T

175 lines
7.1 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
use App\Models\ApiKey;
use App\Models\Deal;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
/** Создаёт активный read-ключ, возвращает plain-ключ. */
function makeApiKey(int $tenantId, int $userId, array $over = []): string
{
$plain = 'lpkapi_'.Str::random(48);
ApiKey::create(array_merge([
'tenant_id' => $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);
});