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); } }