131 lines
4.8 KiB
PHP
131 lines
4.8 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Models\AuditEvent;
|
|
use App\Models\BannedIp;
|
|
use App\Models\Setting;
|
|
use App\Services\BruteforceGuard;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Tests\TestCase;
|
|
|
|
class BruteforceGuardBanTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
Cache::flush();
|
|
}
|
|
|
|
private function guard(): BruteforceGuard
|
|
{
|
|
return app(BruteforceGuard::class);
|
|
}
|
|
|
|
public function test_records_until_threshold_then_bans(): void
|
|
{
|
|
$g = $this->guard();
|
|
Setting::put('bruteforce_maxretry', '3');
|
|
|
|
$g->record('203.0.113.5', 'login');
|
|
$g->record('203.0.113.5', 'login');
|
|
$this->assertFalse($g->isBanned('203.0.113.5'));
|
|
|
|
$g->record('203.0.113.5', 'login'); // 3rd → ban
|
|
$this->assertTrue($g->isBanned('203.0.113.5'));
|
|
$this->assertDatabaseHas('banned_ips', ['ip' => '203.0.113.5', 'reason' => 'login']);
|
|
$this->assertDatabaseHas('audit_events', ['action' => 'auth.ip_banned', 'ip' => '203.0.113.5']);
|
|
}
|
|
|
|
public function test_exempt_ip_is_never_banned(): void
|
|
{
|
|
$g = $this->guard();
|
|
Setting::put('bruteforce_maxretry', '1');
|
|
for ($i = 0; $i < 5; $i++) {
|
|
$g->record('192.168.1.50', 'login'); // private = default whitelist
|
|
}
|
|
$this->assertFalse($g->isBanned('192.168.1.50'));
|
|
$this->assertDatabaseCount('banned_ips', 0);
|
|
}
|
|
|
|
public function test_disabled_records_nothing(): void
|
|
{
|
|
Setting::put('bruteforce_enabled', '0');
|
|
Setting::put('bruteforce_maxretry', '1');
|
|
$this->guard()->record('203.0.113.9', 'login');
|
|
$this->assertDatabaseCount('banned_ips', 0);
|
|
}
|
|
|
|
public function test_unban_clears_row_and_cache(): void
|
|
{
|
|
$g = $this->guard();
|
|
BannedIp::create(['ip' => '203.0.113.5', 'banned_until' => now()->addHour(), 'attempts' => 10]);
|
|
$this->assertTrue($g->isBanned('203.0.113.5')); // populates cache
|
|
$g->unban('203.0.113.5');
|
|
$this->assertFalse($g->isBanned('203.0.113.5')); // no stale positive
|
|
$this->assertDatabaseCount('banned_ips', 0);
|
|
}
|
|
|
|
public function test_expired_ban_is_not_active(): void
|
|
{
|
|
BannedIp::create(['ip' => '203.0.113.5', 'banned_until' => now()->subMinute(), 'attempts' => 10]);
|
|
$this->assertFalse($this->guard()->isBanned('203.0.113.5'));
|
|
}
|
|
|
|
public function test_ipv4_mapped_ipv6_shares_one_ban(): void
|
|
{
|
|
$g = $this->guard();
|
|
Setting::put('bruteforce_maxretry', '1');
|
|
$g->record('203.0.113.5', 'login'); // ban via plain IPv4
|
|
$this->assertTrue($g->isBanned('::ffff:203.0.113.5')); // mapped form is the same ban
|
|
}
|
|
|
|
// ── banNow (honeypot first-hit ban) audit dedup ─────────────────────────────
|
|
|
|
public function test_bannow_audits_once_per_reason_within_the_window(): void
|
|
{
|
|
$g = $this->guard();
|
|
|
|
$g->banNow('203.0.113.8', 'honeypot');
|
|
$g->banNow('203.0.113.8', 'honeypot'); // same ip+reason inside 60s → the flood is deduped
|
|
$this->assertDatabaseCount('audit_events', 1);
|
|
|
|
$g->banNow('203.0.113.8', 'honeytoken'); // a DISTINCT reason is still audited
|
|
$this->assertDatabaseCount('audit_events', 2);
|
|
}
|
|
|
|
public function test_bannow_releases_the_audit_slot_when_the_write_fails(): void
|
|
{
|
|
$g = $this->guard();
|
|
|
|
// Make the FIRST audit insert throw (a transient DB blip), then heal.
|
|
$fail = true;
|
|
AuditEvent::creating(function () use (&$fail) {
|
|
if ($fail) {
|
|
$fail = false;
|
|
throw new \RuntimeException('db blip');
|
|
}
|
|
});
|
|
|
|
// The ban row must still be written; the audit fails and re-throws (the honeypot
|
|
// caller swallows it). The dedup slot must NOT stay claimed after a failed write.
|
|
try {
|
|
$g->banNow('203.0.113.7', 'honeypot');
|
|
} catch (\Throwable) {
|
|
// expected — banNow re-throws the audit failure
|
|
}
|
|
|
|
$this->assertDatabaseHas('banned_ips', ['ip' => '203.0.113.7']); // ban persisted despite audit failure
|
|
$this->assertDatabaseCount('audit_events', 0); // audit did not land
|
|
$this->assertFalse(Cache::has('bruteforce:banaudit:203.0.113.7:honeypot')); // slot released, not burned
|
|
|
|
// A retry inside the same 60s window now re-audits — the failure did not suppress it.
|
|
$g->banNow('203.0.113.7', 'honeypot');
|
|
$this->assertDatabaseHas('audit_events', ['action' => 'auth.ip_banned', 'ip' => '203.0.113.7']);
|
|
$this->assertDatabaseCount('audit_events', 1);
|
|
}
|
|
}
|