155 lines
5.1 KiB
PHP
155 lines
5.1 KiB
PHP
|
|
<?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();
|
|||
|
|
});
|