Files
portal/app/tests/Feature/Auth/IpLockoutTest.php
T
Дмитрий d1e4237e2f phase2(ip-lockout): auth_log записи + 10 неудач/час с IP → 429 (ТЗ §22.4.4 п.2)
- AuthController::isIpLockedOut: count login_failed за час с IP, ≥10 → 429 + Retry-After: 3600
- logAuthEvent: 3 ветки failure_reason (invalid_password / unknown_email / account_locked)
- DB::table('auth_log')->insert; hash-chain trigger заполняет log_hash (OPEN-И-15)
- Защита поверх email-rate-limit: один IP не сможет перебирать множество email'ов
- Pest +6 IpLockoutTest (107/107 за 13.86с, 380 assertions)
- Регресс: Pint+Stan passed
- CLAUDE.md v1.40→v1.41, реестр v1.49→v1.50

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 04:07:17 +03:00

155 lines
5.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\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
uses(DatabaseTransactions::class);
beforeEach(function () {
$this->tenant = Tenant::factory()->create();
});
test('login_success пишет запись в auth_log', function () {
User::factory()->create([
'tenant_id' => $this->tenant->id,
'email' => 'log-success@example.ru',
'password_hash' => Hash::make('right-password'),
]);
$this->postJson('/api/auth/login', [
'email' => 'log-success@example.ru',
'password' => 'right-password',
])->assertOk();
$row = DB::table('auth_log')
->where('email', 'log-success@example.ru')
->where('event', 'login_success')
->first();
expect($row)->not->toBeNull();
expect($row->actor_type)->toBe('tenant_user');
expect($row->tenant_id)->toBe($this->tenant->id);
});
test('login_failed (wrong password) пишет в auth_log с failure_reason=invalid_password', function () {
User::factory()->create([
'tenant_id' => $this->tenant->id,
'email' => 'log-fail@example.ru',
'password_hash' => Hash::make('right-password'),
]);
$this->postJson('/api/auth/login', [
'email' => 'log-fail@example.ru',
'password' => 'wrong-pass-attempt',
])->assertStatus(422);
$row = DB::table('auth_log')
->where('email', 'log-fail@example.ru')
->where('event', 'login_failed')
->first();
expect($row)->not->toBeNull();
expect($row->failure_reason)->toBe('invalid_password');
});
test('login_failed для unknown email пишет с failure_reason=unknown_email', function () {
$this->postJson('/api/auth/login', [
'email' => 'nobody@example.ru',
'password' => 'wrong-pass-attempt',
])->assertStatus(422);
$row = DB::table('auth_log')
->where('email', 'nobody@example.ru')
->where('event', 'login_failed')
->first();
expect($row)->not->toBeNull();
expect($row->failure_reason)->toBe('unknown_email');
expect($row->user_id)->toBeNull();
});
test('IP-lockout: после 10 login_failed с одного IP за час — следующий → 429', function () {
User::factory()->create([
'tenant_id' => $this->tenant->id,
'email' => 'ip-lockout-victim@example.ru',
'password_hash' => Hash::make('right-password'),
]);
// Эмулируем 10 неудачных попыток с разных email с одного IP
// (через прямой INSERT в auth_log, чтобы не триггерить email-rate-limit).
for ($i = 0; $i < 10; $i++) {
DB::table('auth_log')->insert([
'actor_type' => 'tenant_user',
'event' => 'login_failed',
'email' => "victim{$i}@example.ru",
'ip_address' => '127.0.0.1',
'failure_reason' => 'unknown_email',
'created_at' => now()->subMinutes(5),
]);
}
// Следующий login (даже с правильным паролем) → 429.
$r = $this->postJson('/api/auth/login', [
'email' => 'ip-lockout-victim@example.ru',
'password' => 'right-password',
]);
$r->assertStatus(429);
expect($r->json('message'))->toContain('с этого IP');
expect($r->headers->get('Retry-After'))->toBe('3600');
});
test('IP-lockout не срабатывает на 9 неудач (под порогом)', function () {
User::factory()->create([
'tenant_id' => $this->tenant->id,
'email' => 'ok@example.ru',
'password_hash' => Hash::make('right-password'),
]);
for ($i = 0; $i < 9; $i++) {
DB::table('auth_log')->insert([
'actor_type' => 'tenant_user',
'event' => 'login_failed',
'email' => "any{$i}@example.ru",
'ip_address' => '127.0.0.1',
'failure_reason' => 'unknown_email',
'created_at' => now()->subMinutes(5),
]);
}
$this->postJson('/api/auth/login', [
'email' => 'ok@example.ru',
'password' => 'right-password',
])->assertOk();
});
test('IP-lockout: окно 1 час — старые записи (>1ч) не блокируют', function () {
User::factory()->create([
'tenant_id' => $this->tenant->id,
'email' => 'old-fails@example.ru',
'password_hash' => Hash::make('right-password'),
]);
// 15 неудач, но старше часа → не учитываются.
for ($i = 0; $i < 15; $i++) {
DB::table('auth_log')->insert([
'actor_type' => 'tenant_user',
'event' => 'login_failed',
'email' => "old{$i}@example.ru",
'ip_address' => '127.0.0.1',
'failure_reason' => 'unknown_email',
'created_at' => now()->subHours(2),
]);
}
$this->postJson('/api/auth/login', [
'email' => 'old-fails@example.ru',
'password' => 'right-password',
])->assertOk();
});