600 lines
22 KiB
PHP
600 lines
22 KiB
PHP
|
|
<?php
|
||
|
|
|
||
|
|
/*
|
||
|
|
* Core PHP Framework
|
||
|
|
*
|
||
|
|
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||
|
|
* See LICENSE file for details.
|
||
|
|
*/
|
||
|
|
|
||
|
|
declare(strict_types=1);
|
||
|
|
|
||
|
|
use Core\Mod\Hub\Models\HoneypotHit;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Tests for HoneypotHit model methods.
|
||
|
|
*
|
||
|
|
* Covers bot detection, severity classification, query scopes,
|
||
|
|
* fillable attributes, casts, and the getStats() aggregation.
|
||
|
|
*/
|
||
|
|
|
||
|
|
beforeEach(function () {
|
||
|
|
if (! \Illuminate\Support\Facades\Schema::hasTable('honeypot_hits')) {
|
||
|
|
\Illuminate\Support\Facades\Schema::create('honeypot_hits', function ($table) {
|
||
|
|
$table->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);
|
||
|
|
});
|
||
|
|
});
|