id(); $table->string('ip_address', 45); $table->string('user_agent', 1000)->nullable(); $table->string('referer', 2000)->nullable(); $table->string('path', 255); $table->string('method', 10); $table->json('headers')->nullable(); $table->string('country', 2)->nullable(); $table->string('city', 100)->nullable(); $table->boolean('is_bot')->default(false); $table->string('bot_name', 100)->nullable(); $table->string('severity', 20)->default('warning'); $table->timestamps(); $table->index('ip_address'); $table->index('created_at'); $table->index('is_bot'); }); } }); afterEach(function () { HoneypotHit::query()->delete(); }); // ============================================================================= // Bot Detection // ============================================================================= describe('HoneypotHit bot detection', function () { describe('detectBot', function () { it('returns Unknown for null user agent', function () { expect(HoneypotHit::detectBot(null))->toBe('Unknown (no UA)'); }); it('returns Unknown for empty user agent', function () { expect(HoneypotHit::detectBot(''))->toBe('Unknown (no UA)'); }); it('returns null for legitimate browser user agent', function () { $ua = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'; expect(HoneypotHit::detectBot($ua))->toBeNull(); }); it('detects AhrefsBot', function () { $ua = 'Mozilla/5.0 (compatible; AhrefsBot/7.0; +http://ahrefs.com/robot/)'; expect(HoneypotHit::detectBot($ua))->toBe('Ahrefs'); }); it('detects SemrushBot', function () { $ua = 'Mozilla/5.0 (compatible; SemrushBot/7~bl; +http://www.semrush.com/bot.html)'; expect(HoneypotHit::detectBot($ua))->toBe('Semrush'); }); it('detects Googlebot', function () { $ua = 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)'; expect(HoneypotHit::detectBot($ua))->toBe('Google'); }); it('detects GPTBot', function () { $ua = 'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko) GPTBot/1.0'; expect(HoneypotHit::detectBot($ua))->toBe('OpenAI'); }); it('detects ClaudeBot', function () { $ua = 'Mozilla/5.0 ClaudeBot/1.0'; expect(HoneypotHit::detectBot($ua))->toBe('Anthropic'); }); it('detects anthropic-ai', function () { $ua = 'anthropic-ai/1.0'; expect(HoneypotHit::detectBot($ua))->toBe('Anthropic'); }); it('detects curl', function () { $ua = 'curl/7.88.1'; expect(HoneypotHit::detectBot($ua))->toBe('cURL'); }); it('detects python-requests', function () { $ua = 'python-requests/2.31.0'; expect(HoneypotHit::detectBot($ua))->toBe('Python'); }); it('detects Go-http-client', function () { $ua = 'Go-http-client/2.0'; expect(HoneypotHit::detectBot($ua))->toBe('Go'); }); it('detects Wget', function () { $ua = 'Wget/1.21.3'; expect(HoneypotHit::detectBot($ua))->toBe('Wget'); }); it('detects HeadlessChrome', function () { $ua = 'Mozilla/5.0 HeadlessChrome/120.0.0.0 Safari/537.36'; expect(HoneypotHit::detectBot($ua))->toBe('HeadlessChrome'); }); it('detects PhantomJS', function () { $ua = 'Mozilla/5.0 PhantomJS/2.1.1 Safari/538.1'; expect(HoneypotHit::detectBot($ua))->toBe('PhantomJS'); }); it('detects Scrapy', function () { $ua = 'Scrapy/2.11.0 (+https://scrapy.org)'; expect(HoneypotHit::detectBot($ua))->toBe('Scrapy'); }); it('detects Bytespider', function () { $ua = 'Mozilla/5.0 (compatible; Bytespider; spider-feedback@bytedance.com)'; expect(HoneypotHit::detectBot($ua))->toBe('ByteDance'); }); it('performs case-insensitive matching', function () { expect(HoneypotHit::detectBot('AHREFSBOT/7.0'))->toBe('Ahrefs'); expect(HoneypotHit::detectBot('semrushbot'))->toBe('Semrush'); expect(HoneypotHit::detectBot('GOOGLEBOT'))->toBe('Google'); }); }); }); // ============================================================================= // Severity Classification // ============================================================================= describe('HoneypotHit severity classification', function () { describe('severityForPath', function () { it('classifies admin paths as critical', function () { expect(HoneypotHit::severityForPath('/admin'))->toBe('critical'); expect(HoneypotHit::severityForPath('/wp-admin'))->toBe('critical'); expect(HoneypotHit::severityForPath('/wp-login.php'))->toBe('critical'); expect(HoneypotHit::severityForPath('/administrator'))->toBe('critical'); expect(HoneypotHit::severityForPath('/phpmyadmin'))->toBe('critical'); }); it('classifies sensitive file paths as critical', function () { expect(HoneypotHit::severityForPath('/.env'))->toBe('critical'); expect(HoneypotHit::severityForPath('/.git'))->toBe('critical'); }); it('strips leading slashes before matching', function () { expect(HoneypotHit::severityForPath('/admin'))->toBe('critical'); expect(HoneypotHit::severityForPath('admin'))->toBe('critical'); }); it('classifies non-critical paths as warning', function () { expect(HoneypotHit::severityForPath('/teapot'))->toBe('warning'); expect(HoneypotHit::severityForPath('/robots.txt'))->toBe('warning'); expect(HoneypotHit::severityForPath('/some-random-path'))->toBe('warning'); }); it('matches paths that start with a critical prefix', function () { expect(HoneypotHit::severityForPath('/admin/login'))->toBe('critical'); expect(HoneypotHit::severityForPath('/wp-admin/update.php'))->toBe('critical'); expect(HoneypotHit::severityForPath('/.env.bak'))->toBe('critical'); expect(HoneypotHit::severityForPath('/.git/config'))->toBe('critical'); }); }); describe('getSeverityCritical', function () { it('returns default critical string', function () { expect(HoneypotHit::getSeverityCritical())->toBe('critical'); }); }); describe('getSeverityWarning', function () { it('returns default warning string', function () { expect(HoneypotHit::getSeverityWarning())->toBe('warning'); }); }); describe('getCriticalPaths', function () { it('returns default critical paths array', function () { $paths = HoneypotHit::getCriticalPaths(); expect($paths)->toBeArray() ->and($paths)->toContain('admin') ->and($paths)->toContain('wp-admin') ->and($paths)->toContain('wp-login.php') ->and($paths)->toContain('.env') ->and($paths)->toContain('.git'); }); }); describe('severity constants', function () { it('defines SEVERITY_WARNING constant', function () { expect(HoneypotHit::SEVERITY_WARNING)->toBe('warning'); }); it('defines SEVERITY_CRITICAL constant', function () { expect(HoneypotHit::SEVERITY_CRITICAL)->toBe('critical'); }); }); }); // ============================================================================= // Model Configuration // ============================================================================= describe('HoneypotHit model configuration', function () { describe('fillable attributes', function () { it('has the expected fillable fields', function () { $hit = new HoneypotHit(); $fillable = $hit->getFillable(); expect($fillable)->toContain('ip_address') ->and($fillable)->toContain('user_agent') ->and($fillable)->toContain('referer') ->and($fillable)->toContain('path') ->and($fillable)->toContain('method') ->and($fillable)->toContain('headers') ->and($fillable)->toContain('country') ->and($fillable)->toContain('city') ->and($fillable)->toContain('is_bot') ->and($fillable)->toContain('bot_name') ->and($fillable)->toContain('severity'); }); }); describe('casts', function () { it('casts headers to array', function () { $hit = new HoneypotHit(); $casts = $hit->getCasts(); expect($casts['headers'])->toBe('array'); }); it('casts is_bot to boolean', function () { $hit = new HoneypotHit(); $casts = $hit->getCasts(); expect($casts['is_bot'])->toBe('boolean'); }); }); describe('constants', function () { it('defines HEADERS_MAX_COUNT', function () { expect(HoneypotHit::HEADERS_MAX_COUNT)->toBe(50); }); it('defines HEADERS_MAX_SIZE', function () { expect(HoneypotHit::HEADERS_MAX_SIZE)->toBe(16_384); }); }); }); // ============================================================================= // Query Scopes // ============================================================================= describe('HoneypotHit query scopes', function () { describe('scopeRecent', function () { it('filters to hits within given hours', function () { // Create and then backdate to control created_at precisely $recentHit = HoneypotHit::create([ 'ip_address' => '10.0.0.1', 'path' => '/teapot', 'method' => 'GET', 'severity' => 'warning', ]); $recentHit->forceFill(['created_at' => now()->subHours(2)])->save(); $oldHit = HoneypotHit::create([ 'ip_address' => '10.0.0.2', 'path' => '/teapot', 'method' => 'GET', 'severity' => 'warning', ]); $oldHit->forceFill(['created_at' => now()->subHours(48)])->save(); expect(HoneypotHit::recent(24)->count())->toBe(1); expect(HoneypotHit::recent(72)->count())->toBe(2); }); it('defaults to 24 hours', function () { $recentHit = HoneypotHit::create([ 'ip_address' => '10.0.0.1', 'path' => '/teapot', 'method' => 'GET', 'severity' => 'warning', ]); $recentHit->forceFill(['created_at' => now()->subHours(12)])->save(); $oldHit = HoneypotHit::create([ 'ip_address' => '10.0.0.2', 'path' => '/teapot', 'method' => 'GET', 'severity' => 'warning', ]); $oldHit->forceFill(['created_at' => now()->subHours(30)])->save(); expect(HoneypotHit::recent()->count())->toBe(1); }); }); describe('scopeFromIp', function () { it('filters by IP address', function () { HoneypotHit::create([ 'ip_address' => '192.168.1.1', 'path' => '/teapot', 'method' => 'GET', 'severity' => 'warning', ]); HoneypotHit::create([ 'ip_address' => '10.0.0.1', 'path' => '/admin', 'method' => 'GET', 'severity' => 'critical', ]); expect(HoneypotHit::fromIp('192.168.1.1')->count())->toBe(1); expect(HoneypotHit::fromIp('10.0.0.1')->count())->toBe(1); expect(HoneypotHit::fromIp('172.16.0.1')->count())->toBe(0); }); }); describe('scopeBots', function () { it('filters to bot hits only', function () { HoneypotHit::create([ 'ip_address' => '10.0.0.1', 'path' => '/teapot', 'method' => 'GET', 'severity' => 'warning', 'is_bot' => true, 'bot_name' => 'Ahrefs', ]); HoneypotHit::create([ 'ip_address' => '10.0.0.2', 'path' => '/teapot', 'method' => 'GET', 'severity' => 'warning', 'is_bot' => false, ]); expect(HoneypotHit::bots()->count())->toBe(1); }); }); describe('scopeCritical', function () { it('filters to critical severity hits', function () { HoneypotHit::create([ 'ip_address' => '10.0.0.1', 'path' => '/admin', 'method' => 'GET', 'severity' => 'critical', ]); HoneypotHit::create([ 'ip_address' => '10.0.0.2', 'path' => '/teapot', 'method' => 'GET', 'severity' => 'warning', ]); expect(HoneypotHit::critical()->count())->toBe(1); }); }); describe('scopeWarning', function () { it('filters to warning severity hits', function () { HoneypotHit::create([ 'ip_address' => '10.0.0.1', 'path' => '/admin', 'method' => 'GET', 'severity' => 'critical', ]); HoneypotHit::create([ 'ip_address' => '10.0.0.2', 'path' => '/teapot', 'method' => 'GET', 'severity' => 'warning', ]); expect(HoneypotHit::warning()->count())->toBe(1); }); }); describe('scope chaining', function () { it('chains bots and critical scopes', function () { HoneypotHit::create([ 'ip_address' => '10.0.0.1', 'path' => '/admin', 'method' => 'GET', 'severity' => 'critical', 'is_bot' => true, 'bot_name' => 'Ahrefs', ]); HoneypotHit::create([ 'ip_address' => '10.0.0.2', 'path' => '/teapot', 'method' => 'GET', 'severity' => 'warning', 'is_bot' => true, 'bot_name' => 'Semrush', ]); HoneypotHit::create([ 'ip_address' => '10.0.0.3', 'path' => '/admin', 'method' => 'GET', 'severity' => 'critical', 'is_bot' => false, ]); expect(HoneypotHit::bots()->critical()->count())->toBe(1); }); it('chains fromIp and recent scopes', function () { $hit1 = HoneypotHit::create([ 'ip_address' => '192.168.1.1', 'path' => '/teapot', 'method' => 'GET', 'severity' => 'warning', ]); $hit1->forceFill(['created_at' => now()->subHours(2)])->save(); $hit2 = HoneypotHit::create([ 'ip_address' => '192.168.1.1', 'path' => '/admin', 'method' => 'GET', 'severity' => 'critical', ]); $hit2->forceFill(['created_at' => now()->subHours(48)])->save(); HoneypotHit::create([ 'ip_address' => '10.0.0.1', 'path' => '/teapot', 'method' => 'GET', 'severity' => 'warning', ]); expect(HoneypotHit::fromIp('192.168.1.1')->recent(24)->count())->toBe(1); }); }); }); // ============================================================================= // Mass Assignment & Persistence // ============================================================================= describe('HoneypotHit mass assignment', function () { it('creates a record with all fillable attributes', function () { $hit = HoneypotHit::create([ 'ip_address' => '203.0.113.50', 'user_agent' => 'AhrefsBot/7.0', 'referer' => 'https://example.com', 'path' => '/wp-admin', 'method' => 'GET', 'headers' => ['host' => ['example.com']], 'country' => 'US', 'city' => 'New York', 'is_bot' => true, 'bot_name' => 'Ahrefs', 'severity' => 'critical', ]); $fresh = HoneypotHit::find($hit->id); expect($fresh->ip_address)->toBe('203.0.113.50') ->and($fresh->user_agent)->toBe('AhrefsBot/7.0') ->and($fresh->referer)->toBe('https://example.com') ->and($fresh->path)->toBe('/wp-admin') ->and($fresh->method)->toBe('GET') ->and($fresh->headers)->toBe(['host' => ['example.com']]) ->and($fresh->country)->toBe('US') ->and($fresh->city)->toBe('New York') ->and($fresh->is_bot)->toBeTrue() ->and($fresh->bot_name)->toBe('Ahrefs') ->and($fresh->severity)->toBe('critical'); }); it('creates a minimal record with required fields only', function () { $hit = HoneypotHit::create([ 'ip_address' => '127.0.0.1', 'path' => '/teapot', 'method' => 'GET', 'severity' => 'warning', ]); $fresh = HoneypotHit::find($hit->id); expect($fresh->ip_address)->toBe('127.0.0.1') ->and($fresh->path)->toBe('/teapot') ->and($fresh->method)->toBe('GET') ->and($fresh->severity)->toBe('warning') ->and($fresh->user_agent)->toBeNull() ->and($fresh->bot_name)->toBeNull(); }); }); // ============================================================================= // getStats // ============================================================================= describe('HoneypotHit getStats', function () { it('returns correct stats structure', function () { $stats = HoneypotHit::getStats(); expect($stats)->toBeArray() ->and($stats)->toHaveKey('total') ->and($stats)->toHaveKey('today') ->and($stats)->toHaveKey('this_week') ->and($stats)->toHaveKey('unique_ips') ->and($stats)->toHaveKey('bots') ->and($stats)->toHaveKey('top_ips') ->and($stats)->toHaveKey('top_bots'); }); it('returns zero counts when empty', function () { $stats = HoneypotHit::getStats(); expect($stats['total'])->toBe(0) ->and($stats['today'])->toBe(0) ->and($stats['this_week'])->toBe(0) ->and($stats['unique_ips'])->toBe(0) ->and($stats['bots'])->toBe(0); }); it('counts total hits correctly', function () { HoneypotHit::create(['ip_address' => '10.0.0.1', 'path' => '/a', 'method' => 'GET', 'severity' => 'warning']); HoneypotHit::create(['ip_address' => '10.0.0.2', 'path' => '/b', 'method' => 'GET', 'severity' => 'critical']); HoneypotHit::create(['ip_address' => '10.0.0.3', 'path' => '/c', 'method' => 'POST', 'severity' => 'warning']); $stats = HoneypotHit::getStats(); expect($stats['total'])->toBe(3); }); it('counts unique IPs correctly', function () { HoneypotHit::create(['ip_address' => '10.0.0.1', 'path' => '/a', 'method' => 'GET', 'severity' => 'warning']); HoneypotHit::create(['ip_address' => '10.0.0.1', 'path' => '/b', 'method' => 'GET', 'severity' => 'warning']); HoneypotHit::create(['ip_address' => '10.0.0.2', 'path' => '/c', 'method' => 'GET', 'severity' => 'warning']); $stats = HoneypotHit::getStats(); expect($stats['unique_ips'])->toBe(2); }); it('counts bots correctly', function () { HoneypotHit::create(['ip_address' => '10.0.0.1', 'path' => '/a', 'method' => 'GET', 'severity' => 'warning', 'is_bot' => true, 'bot_name' => 'Ahrefs']); HoneypotHit::create(['ip_address' => '10.0.0.2', 'path' => '/b', 'method' => 'GET', 'severity' => 'warning', 'is_bot' => false]); $stats = HoneypotHit::getStats(); expect($stats['bots'])->toBe(1); }); it('aggregates top IPs', function () { HoneypotHit::create(['ip_address' => '10.0.0.1', 'path' => '/a', 'method' => 'GET', 'severity' => 'warning']); HoneypotHit::create(['ip_address' => '10.0.0.1', 'path' => '/b', 'method' => 'GET', 'severity' => 'warning']); HoneypotHit::create(['ip_address' => '10.0.0.2', 'path' => '/c', 'method' => 'GET', 'severity' => 'warning']); $stats = HoneypotHit::getStats(); expect($stats['top_ips'])->toHaveCount(2); expect($stats['top_ips']->first()->ip_address)->toBe('10.0.0.1'); expect($stats['top_ips']->first()->hits)->toBe(2); }); it('aggregates top bots', function () { HoneypotHit::create(['ip_address' => '10.0.0.1', 'path' => '/a', 'method' => 'GET', 'severity' => 'warning', 'is_bot' => true, 'bot_name' => 'Ahrefs']); HoneypotHit::create(['ip_address' => '10.0.0.2', 'path' => '/b', 'method' => 'GET', 'severity' => 'warning', 'is_bot' => true, 'bot_name' => 'Ahrefs']); HoneypotHit::create(['ip_address' => '10.0.0.3', 'path' => '/c', 'method' => 'GET', 'severity' => 'warning', 'is_bot' => true, 'bot_name' => 'Semrush']); $stats = HoneypotHit::getStats(); expect($stats['top_bots'])->toHaveCount(2); expect($stats['top_bots']->first()->bot_name)->toBe('Ahrefs'); expect($stats['top_bots']->first()->hits)->toBe(2); }); });