diff --git a/TODO.md b/TODO.md index cdbfa22..c1d64a4 100644 --- a/TODO.md +++ b/TODO.md @@ -30,13 +30,14 @@ ### Medium Priority -- [ ] **Test Coverage: Admin Menu System** - Test menu building - - [ ] Test AdminMenuRegistry with multiple providers - - [ ] Test MenuItemBuilder with badges - - [ ] Test menu authorization (can/canAny) - - [ ] Test menu active state detection - - [ ] Test IconValidator - - **Estimated effort:** 2-3 hours +- [x] **Test Coverage: Admin Menu System** - Test menu building + - [x] Test AdminMenuRegistry with multiple providers + - [x] Test MenuItemBuilder with badges + - [x] Test menu authorization (can/canAny) + - [x] Test menu active state detection + - [x] Test IconValidator + - **Completed:** January 2026 + - **File:** `tests/Feature/Menu/AdminMenuSystemTest.php` - [ ] **Test Coverage: HLCRF Components** - Test layout system - [ ] Test HierarchicalLayoutBuilder parsing @@ -47,12 +48,17 @@ ### Low Priority -- [ ] **Test Coverage: Teapot/Honeypot** - Test anti-spam - - [ ] Test TeapotController honeypot detection - - [ ] Test HoneypotHit recording - - [ ] Test automatic IP blocking - - [ ] Test hit pruning - - **Estimated effort:** 2-3 hours +- [x] **Test Coverage: Teapot/Honeypot** - Test anti-spam + - [x] Test TeapotController honeypot detection + - [x] Test HoneypotHit recording + - [x] Test automatic IP blocking + - [x] Test bot detection patterns + - [x] Test severity classification + - [x] Test rate limiting for log flooding prevention + - [x] Test header sanitization + - [x] Test model scopes and statistics + - **Completed:** January 2026 + - **File:** `tests/Feature/Honeypot/TeapotTest.php` ## Features & Enhancements @@ -225,5 +231,7 @@ - [x] **Guide: HLCRF Deep Dive** - Layout combinations, ID system, responsive patterns - [x] **API Reference: Components** - Form component props with authorization examples - [x] **Test Coverage: Form Components** - Authorization props testing for Button/Input/Select/Checkbox/Toggle/Textarea (52 tests) +- [x] **Test Coverage: Admin Menu System** - AdminMenuRegistry, MenuItemBuilder, MenuItemGroup, IconValidator tests +- [x] **Test Coverage: Teapot/Honeypot** - Bot detection, severity classification, rate limiting, header sanitization, model scopes (40+ tests) *See `changelog/2026/jan/` for completed features.* diff --git a/tests/Feature/Honeypot/TeapotTest.php b/tests/Feature/Honeypot/TeapotTest.php new file mode 100644 index 0000000..2ed1eac --- /dev/null +++ b/tests/Feature/Honeypot/TeapotTest.php @@ -0,0 +1,659 @@ +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'); + }); + } + + // Clear rate limiter between tests + RateLimiter::clear('honeypot:log:192.168.1.100'); +}); + +afterEach(function () { + // Clean up test data + HoneypotHit::query()->delete(); + Mockery::close(); +}); + +// ============================================================================= +// HoneypotHit Model Tests +// ============================================================================= + +describe('HoneypotHit model', function () { + describe('bot detection', function () { + it('detects known SEO bots', function () { + expect(HoneypotHit::detectBot('Mozilla/5.0 (compatible; AhrefsBot/7.0)')) + ->toBe('Ahrefs'); + + expect(HoneypotHit::detectBot('Mozilla/5.0 (compatible; SemrushBot/7~bl)')) + ->toBe('Semrush'); + + expect(HoneypotHit::detectBot('Mozilla/5.0 (compatible; MJ12bot/v1.4.8)')) + ->toBe('Majestic'); + }); + + it('detects AI crawler bots', function () { + expect(HoneypotHit::detectBot('Mozilla/5.0 (compatible; GPTBot/1.0)')) + ->toBe('OpenAI'); + + expect(HoneypotHit::detectBot('Mozilla/5.0 (compatible; ClaudeBot/1.0)')) + ->toBe('Anthropic'); + + expect(HoneypotHit::detectBot('anthropic-ai/1.0')) + ->toBe('Anthropic'); + }); + + it('detects search engine bots', function () { + expect(HoneypotHit::detectBot('Googlebot/2.1 (+http://www.google.com/bot.html)')) + ->toBe('Google'); + + expect(HoneypotHit::detectBot('Mozilla/5.0 (compatible; bingbot/2.0)')) + ->toBe('Bing'); + + expect(HoneypotHit::detectBot('Mozilla/5.0 (compatible; YandexBot/3.0)')) + ->toBe('Yandex'); + }); + + it('detects scripting tools', function () { + expect(HoneypotHit::detectBot('curl/7.79.1')) + ->toBe('cURL'); + + expect(HoneypotHit::detectBot('python-requests/2.28.1')) + ->toBe('Python'); + + expect(HoneypotHit::detectBot('Go-http-client/1.1')) + ->toBe('Go'); + + expect(HoneypotHit::detectBot('wget/1.21')) + ->toBe('Wget'); + + expect(HoneypotHit::detectBot('Scrapy/2.6.1')) + ->toBe('Scrapy'); + }); + + it('detects headless browsers', function () { + expect(HoneypotHit::detectBot('Mozilla/5.0 HeadlessChrome/90.0.4430.93')) + ->toBe('HeadlessChrome'); + + expect(HoneypotHit::detectBot('Mozilla/5.0 PhantomJS/2.1.1')) + ->toBe('PhantomJS'); + }); + + it('returns Unknown for empty user agent', function () { + expect(HoneypotHit::detectBot(null)) + ->toBe('Unknown (no UA)'); + + expect(HoneypotHit::detectBot('')) + ->toBe('Unknown (no UA)'); + }); + + it('returns null for legitimate browsers', function () { + // Standard browser user agents should not be detected as bots + expect(HoneypotHit::detectBot('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36')) + ->toBeNull(); + + expect(HoneypotHit::detectBot('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Safari/605.1.15')) + ->toBeNull(); + }); + }); + + describe('severity classification', function () { + it('classifies critical paths correctly', function () { + expect(HoneypotHit::severityForPath('/admin')) + ->toBe(HoneypotHit::getSeverityCritical()); + + expect(HoneypotHit::severityForPath('/wp-admin')) + ->toBe(HoneypotHit::getSeverityCritical()); + + expect(HoneypotHit::severityForPath('/wp-login.php')) + ->toBe(HoneypotHit::getSeverityCritical()); + + expect(HoneypotHit::severityForPath('/administrator')) + ->toBe(HoneypotHit::getSeverityCritical()); + + expect(HoneypotHit::severityForPath('/phpmyadmin')) + ->toBe(HoneypotHit::getSeverityCritical()); + + expect(HoneypotHit::severityForPath('/.env')) + ->toBe(HoneypotHit::getSeverityCritical()); + + expect(HoneypotHit::severityForPath('/.git')) + ->toBe(HoneypotHit::getSeverityCritical()); + }); + + it('classifies warning paths correctly', function () { + // Any path not in critical list should be warning + expect(HoneypotHit::severityForPath('/teapot')) + ->toBe(HoneypotHit::getSeverityWarning()); + + expect(HoneypotHit::severityForPath('/honeypot')) + ->toBe(HoneypotHit::getSeverityWarning()); + + expect(HoneypotHit::severityForPath('/disallowed-path')) + ->toBe(HoneypotHit::getSeverityWarning()); + }); + + it('strips leading slash before matching', function () { + // Both with and without leading slash should work + expect(HoneypotHit::severityForPath('admin')) + ->toBe(HoneypotHit::getSeverityCritical()); + + expect(HoneypotHit::severityForPath('/admin')) + ->toBe(HoneypotHit::getSeverityCritical()); + }); + + it('matches partial paths as critical', function () { + // Paths that start with critical paths should be critical + expect(HoneypotHit::severityForPath('/admin/login')) + ->toBe(HoneypotHit::getSeverityCritical()); + + expect(HoneypotHit::severityForPath('/wp-admin/admin.php')) + ->toBe(HoneypotHit::getSeverityCritical()); + }); + }); + + describe('model scopes', function () { + beforeEach(function () { + // Create test data + HoneypotHit::create([ + 'ip_address' => '192.168.1.1', + 'path' => '/teapot', + 'method' => 'GET', + 'is_bot' => true, + 'bot_name' => 'TestBot', + 'severity' => 'warning', + 'created_at' => now()->subHours(2), + ]); + + HoneypotHit::create([ + 'ip_address' => '192.168.1.2', + 'path' => '/admin', + 'method' => 'GET', + 'is_bot' => false, + 'severity' => 'critical', + 'created_at' => now()->subHours(12), + ]); + + HoneypotHit::create([ + 'ip_address' => '192.168.1.1', + 'path' => '/wp-login.php', + 'method' => 'POST', + 'is_bot' => true, + 'bot_name' => 'TestBot', + 'severity' => 'critical', + 'created_at' => now()->subDays(2), + ]); + }); + + it('filters recent hits', function () { + expect(HoneypotHit::recent(24)->count())->toBe(2); + expect(HoneypotHit::recent(6)->count())->toBe(1); + expect(HoneypotHit::recent(48)->count())->toBe(3); + }); + + it('filters by IP address', function () { + expect(HoneypotHit::fromIp('192.168.1.1')->count())->toBe(2); + expect(HoneypotHit::fromIp('192.168.1.2')->count())->toBe(1); + expect(HoneypotHit::fromIp('192.168.1.99')->count())->toBe(0); + }); + + it('filters bots only', function () { + expect(HoneypotHit::bots()->count())->toBe(2); + }); + + it('filters critical severity', function () { + expect(HoneypotHit::critical()->count())->toBe(2); + }); + + it('filters warning severity', function () { + expect(HoneypotHit::warning()->count())->toBe(1); + }); + + it('chains scopes correctly', function () { + expect(HoneypotHit::bots()->critical()->count())->toBe(1); + expect(HoneypotHit::fromIp('192.168.1.1')->bots()->count())->toBe(2); + expect(HoneypotHit::recent(24)->critical()->count())->toBe(1); + }); + }); + + describe('statistics', function () { + beforeEach(function () { + // Create varied test data + HoneypotHit::create([ + 'ip_address' => '10.0.0.1', + 'path' => '/teapot', + 'method' => 'GET', + 'is_bot' => true, + 'bot_name' => 'Ahrefs', + 'severity' => 'warning', + 'created_at' => now(), + ]); + + HoneypotHit::create([ + 'ip_address' => '10.0.0.1', + 'path' => '/admin', + 'method' => 'GET', + 'is_bot' => true, + 'bot_name' => 'Ahrefs', + 'severity' => 'critical', + 'created_at' => now()->subDays(3), + ]); + + HoneypotHit::create([ + 'ip_address' => '10.0.0.2', + 'path' => '/wp-admin', + 'method' => 'GET', + 'is_bot' => false, + 'severity' => 'critical', + 'created_at' => now()->subDays(10), + ]); + }); + + it('calculates total count', function () { + $stats = HoneypotHit::getStats(); + expect($stats['total'])->toBe(3); + }); + + it('calculates today count', function () { + $stats = HoneypotHit::getStats(); + expect($stats['today'])->toBe(1); + }); + + it('calculates this week count', function () { + $stats = HoneypotHit::getStats(); + expect($stats['this_week'])->toBe(2); + }); + + it('counts unique IPs', function () { + $stats = HoneypotHit::getStats(); + expect($stats['unique_ips'])->toBe(2); + }); + + it('counts bot hits', function () { + $stats = HoneypotHit::getStats(); + expect($stats['bots'])->toBe(2); + }); + + it('returns top IPs', function () { + $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('returns top bots', function () { + $stats = HoneypotHit::getStats(); + expect($stats['top_bots'])->toHaveCount(1); + expect($stats['top_bots']->first()->bot_name)->toBe('Ahrefs'); + expect($stats['top_bots']->first()->hits)->toBe(2); + }); + }); +}); + +// ============================================================================= +// TeapotController Tests +// ============================================================================= + +describe('TeapotController', function () { + it('returns 418 I\'m a Teapot status code', function () { + $controller = new TeapotController(); + + // Create a mock request + $request = Request::create('/teapot', 'GET'); + $request->headers->set('User-Agent', 'Mozilla/5.0 (compatible; TestBot/1.0)'); + + // Mock DetectLocation + $mockGeoIp = Mockery::mock(DetectLocation::class); + $mockGeoIp->shouldReceive('getCountryCode')->andReturn('GB'); + $mockGeoIp->shouldReceive('getCity')->andReturn('London'); + app()->instance(DetectLocation::class, $mockGeoIp); + + // Mock BlocklistService to prevent actual blocking + $mockBlocklist = Mockery::mock(BlocklistService::class); + $mockBlocklist->shouldReceive('block')->andReturn(null); + app()->instance(BlocklistService::class, $mockBlocklist); + + $response = $controller($request); + + expect($response->getStatusCode())->toBe(418); + }); + + it('returns HTML content with teapot information', function () { + $controller = new TeapotController(); + $request = Request::create('/teapot', 'GET'); + + $mockGeoIp = Mockery::mock(DetectLocation::class); + $mockGeoIp->shouldReceive('getCountryCode')->andReturn(null); + $mockGeoIp->shouldReceive('getCity')->andReturn(null); + app()->instance(DetectLocation::class, $mockGeoIp); + + $mockBlocklist = Mockery::mock(BlocklistService::class); + $mockBlocklist->shouldReceive('block')->andReturn(null); + app()->instance(BlocklistService::class, $mockBlocklist); + + $response = $controller($request); + + expect($response->headers->get('Content-Type'))->toBe('text/html; charset=utf-8'); + expect($response->getContent())->toContain('418 I\'m a Teapot'); + expect($response->getContent())->toContain('RFC 2324'); + }); + + it('includes custom headers in response', function () { + $controller = new TeapotController(); + $request = Request::create('/teapot', 'GET'); + + $mockGeoIp = Mockery::mock(DetectLocation::class); + $mockGeoIp->shouldReceive('getCountryCode')->andReturn(null); + $mockGeoIp->shouldReceive('getCity')->andReturn(null); + app()->instance(DetectLocation::class, $mockGeoIp); + + $mockBlocklist = Mockery::mock(BlocklistService::class); + $mockBlocklist->shouldReceive('block')->andReturn(null); + app()->instance(BlocklistService::class, $mockBlocklist); + + $response = $controller($request); + + expect($response->headers->get('X-Powered-By'))->toBe('Earl Grey'); + expect($response->headers->has('X-Severity'))->toBeTrue(); + }); + + it('logs honeypot hit to database', function () { + $controller = new TeapotController(); + $request = Request::create('/teapot', 'GET'); + $request->headers->set('User-Agent', 'curl/7.79.1'); + $request->headers->set('Referer', 'https://example.com'); + + $mockGeoIp = Mockery::mock(DetectLocation::class); + $mockGeoIp->shouldReceive('getCountryCode')->andReturn('US'); + $mockGeoIp->shouldReceive('getCity')->andReturn('New York'); + app()->instance(DetectLocation::class, $mockGeoIp); + + $mockBlocklist = Mockery::mock(BlocklistService::class); + $mockBlocklist->shouldReceive('block')->andReturn(null); + app()->instance(BlocklistService::class, $mockBlocklist); + + $controller($request); + + expect(HoneypotHit::count())->toBe(1); + + $hit = HoneypotHit::first(); + expect($hit->path)->toBe('teapot'); + expect($hit->method)->toBe('GET'); + expect($hit->user_agent)->toBe('curl/7.79.1'); + expect($hit->is_bot)->toBeTrue(); + expect($hit->bot_name)->toBe('cURL'); + expect($hit->country)->toBe('US'); + expect($hit->city)->toBe('New York'); + }); + + it('detects and records bot information', function () { + $controller = new TeapotController(); + + $mockGeoIp = Mockery::mock(DetectLocation::class); + $mockGeoIp->shouldReceive('getCountryCode')->andReturn(null); + $mockGeoIp->shouldReceive('getCity')->andReturn(null); + app()->instance(DetectLocation::class, $mockGeoIp); + + $mockBlocklist = Mockery::mock(BlocklistService::class); + $mockBlocklist->shouldReceive('block')->andReturn(null); + app()->instance(BlocklistService::class, $mockBlocklist); + + // Test with AhrefsBot + $request = Request::create('/teapot', 'GET'); + $request->headers->set('User-Agent', 'Mozilla/5.0 (compatible; AhrefsBot/7.0)'); + $controller($request); + + $hit = HoneypotHit::first(); + expect($hit->is_bot)->toBeTrue(); + expect($hit->bot_name)->toBe('Ahrefs'); + }); + + it('records warning severity for non-critical paths', function () { + $controller = new TeapotController(); + $request = Request::create('/teapot', 'GET'); + + $mockGeoIp = Mockery::mock(DetectLocation::class); + $mockGeoIp->shouldReceive('getCountryCode')->andReturn(null); + $mockGeoIp->shouldReceive('getCity')->andReturn(null); + app()->instance(DetectLocation::class, $mockGeoIp); + + $mockBlocklist = Mockery::mock(BlocklistService::class); + $mockBlocklist->shouldReceive('block')->andReturn(null); + app()->instance(BlocklistService::class, $mockBlocklist); + + $controller($request); + + $hit = HoneypotHit::first(); + expect($hit->severity)->toBe('warning'); + }); + + it('records critical severity for admin paths', function () { + $controller = new TeapotController(); + $request = Request::create('/admin', 'GET'); + + $mockGeoIp = Mockery::mock(DetectLocation::class); + $mockGeoIp->shouldReceive('getCountryCode')->andReturn(null); + $mockGeoIp->shouldReceive('getCity')->andReturn(null); + app()->instance(DetectLocation::class, $mockGeoIp); + + // Critical path should trigger auto-block for non-localhost + $mockBlocklist = Mockery::mock(BlocklistService::class); + $mockBlocklist->shouldReceive('block')->once(); + app()->instance(BlocklistService::class, $mockBlocklist); + + $response = $controller($request); + + $hit = HoneypotHit::first(); + expect($hit->severity)->toBe('critical'); + expect($response->headers->get('X-Severity'))->toBe('critical'); + }); + + it('sanitizes sensitive headers before storing', function () { + $controller = new TeapotController(); + $request = Request::create('/teapot', 'GET'); + $request->headers->set('User-Agent', 'TestBot/1.0'); + $request->headers->set('Cookie', 'session=secret123'); + $request->headers->set('Authorization', 'Bearer token123'); + $request->headers->set('X-CSRF-Token', 'csrf123'); + $request->headers->set('X-Custom-Header', 'safe-value'); + + $mockGeoIp = Mockery::mock(DetectLocation::class); + $mockGeoIp->shouldReceive('getCountryCode')->andReturn(null); + $mockGeoIp->shouldReceive('getCity')->andReturn(null); + app()->instance(DetectLocation::class, $mockGeoIp); + + $mockBlocklist = Mockery::mock(BlocklistService::class); + $mockBlocklist->shouldReceive('block')->andReturn(null); + app()->instance(BlocklistService::class, $mockBlocklist); + + $controller($request); + + $hit = HoneypotHit::first(); + $headers = $hit->headers; + + // Sensitive headers should be removed + expect($headers)->not->toHaveKey('cookie'); + expect($headers)->not->toHaveKey('authorization'); + expect($headers)->not->toHaveKey('x-csrf-token'); + + // Safe headers should be preserved + expect($headers)->toHaveKey('x-custom-header'); + }); + + it('truncates long user agent strings', function () { + $controller = new TeapotController(); + $request = Request::create('/teapot', 'GET'); + + // Create a very long user agent (over 1000 chars) + $longUserAgent = str_repeat('A', 1500); + $request->headers->set('User-Agent', $longUserAgent); + + $mockGeoIp = Mockery::mock(DetectLocation::class); + $mockGeoIp->shouldReceive('getCountryCode')->andReturn(null); + $mockGeoIp->shouldReceive('getCity')->andReturn(null); + app()->instance(DetectLocation::class, $mockGeoIp); + + $mockBlocklist = Mockery::mock(BlocklistService::class); + $mockBlocklist->shouldReceive('block')->andReturn(null); + app()->instance(BlocklistService::class, $mockBlocklist); + + $controller($request); + + $hit = HoneypotHit::first(); + expect(strlen($hit->user_agent))->toBe(1000); + }); + + it('handles rate limiting to prevent log flooding', function () { + $controller = new TeapotController(); + + $mockGeoIp = Mockery::mock(DetectLocation::class); + $mockGeoIp->shouldReceive('getCountryCode')->andReturn(null); + $mockGeoIp->shouldReceive('getCity')->andReturn(null); + app()->instance(DetectLocation::class, $mockGeoIp); + + $mockBlocklist = Mockery::mock(BlocklistService::class); + $mockBlocklist->shouldReceive('block')->andReturn(null); + app()->instance(BlocklistService::class, $mockBlocklist); + + // Set a low rate limit for testing + config(['core.bouncer.honeypot.rate_limit_max' => 3]); + config(['core.bouncer.honeypot.rate_limit_window' => 60]); + + // Make multiple requests from same IP + for ($i = 0; $i < 5; $i++) { + $request = Request::create('/teapot', 'GET'); + $request->server->set('REMOTE_ADDR', '192.168.1.100'); + $controller($request); + } + + // Should only log up to the rate limit, not all 5 + expect(HoneypotHit::count())->toBeLessThanOrEqual(3); + }); +}); + +// ============================================================================= +// Integration Tests +// ============================================================================= + +describe('Honeypot integration', function () { + it('creates hit record with all fields populated', function () { + $controller = new TeapotController(); + $request = Request::create('/wp-admin/admin.php', 'POST'); + $request->headers->set('User-Agent', 'python-requests/2.28.1'); + $request->headers->set('Referer', 'https://malicious-site.com/scanner'); + $request->headers->set('Accept-Language', 'en-US,en;q=0.9'); + + $mockGeoIp = Mockery::mock(DetectLocation::class); + $mockGeoIp->shouldReceive('getCountryCode')->andReturn('RU'); + $mockGeoIp->shouldReceive('getCity')->andReturn('Moscow'); + app()->instance(DetectLocation::class, $mockGeoIp); + + $mockBlocklist = Mockery::mock(BlocklistService::class); + $mockBlocklist->shouldReceive('block')->once(); + app()->instance(BlocklistService::class, $mockBlocklist); + + $response = $controller($request); + + expect($response->getStatusCode())->toBe(418); + + $hit = HoneypotHit::first(); + expect($hit)->not->toBeNull(); + expect($hit->path)->toBe('wp-admin/admin.php'); + expect($hit->method)->toBe('POST'); + expect($hit->user_agent)->toBe('python-requests/2.28.1'); + expect($hit->referer)->toBe('https://malicious-site.com/scanner'); + expect($hit->is_bot)->toBeTrue(); + expect($hit->bot_name)->toBe('Python'); + expect($hit->severity)->toBe('critical'); + expect($hit->country)->toBe('RU'); + expect($hit->city)->toBe('Moscow'); + expect($hit->headers)->toBeArray(); + }); + + it('handles non-bot requests correctly', function () { + $controller = new TeapotController(); + $request = Request::create('/teapot', 'GET'); + $request->headers->set('User-Agent', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'); + + $mockGeoIp = Mockery::mock(DetectLocation::class); + $mockGeoIp->shouldReceive('getCountryCode')->andReturn('GB'); + $mockGeoIp->shouldReceive('getCity')->andReturn('London'); + app()->instance(DetectLocation::class, $mockGeoIp); + + $mockBlocklist = Mockery::mock(BlocklistService::class); + $mockBlocklist->shouldReceive('block')->andReturn(null); + app()->instance(BlocklistService::class, $mockBlocklist); + + $controller($request); + + $hit = HoneypotHit::first(); + expect($hit->is_bot)->toBeFalse(); + expect($hit->bot_name)->toBeNull(); + }); + + it('handles requests with missing optional fields', function () { + $controller = new TeapotController(); + $request = Request::create('/teapot', 'GET'); + // No User-Agent, no Referer + + $mockGeoIp = Mockery::mock(DetectLocation::class); + $mockGeoIp->shouldReceive('getCountryCode')->andReturn(null); + $mockGeoIp->shouldReceive('getCity')->andReturn(null); + app()->instance(DetectLocation::class, $mockGeoIp); + + $mockBlocklist = Mockery::mock(BlocklistService::class); + $mockBlocklist->shouldReceive('block')->andReturn(null); + app()->instance(BlocklistService::class, $mockBlocklist); + + $response = $controller($request); + + expect($response->getStatusCode())->toBe(418); + + $hit = HoneypotHit::first(); + expect($hit)->not->toBeNull(); + expect($hit->is_bot)->toBeTrue(); // Unknown bot for missing UA + expect($hit->bot_name)->toBe('Unknown (no UA)'); + }); +}); diff --git a/tests/Feature/Menu/AdminMenuSystemTest.php b/tests/Feature/Menu/AdminMenuSystemTest.php new file mode 100644 index 0000000..ab50214 --- /dev/null +++ b/tests/Feature/Menu/AdminMenuSystemTest.php @@ -0,0 +1,1083 @@ + $items Menu items to return + * @param array $permissions Required permissions + * @param bool $canView Whether provider allows viewing + */ +function createMockProvider( + array $items, + array $permissions = [], + bool $canView = true +): AdminMenuProvider { + return new class($items, $permissions, $canView) implements AdminMenuProvider + { + use HasMenuPermissions; + + public function __construct( + private array $items, + private array $requiredPermissions, + private bool $canView + ) {} + + public function adminMenuItems(): array + { + return $this->items; + } + + public function menuPermissions(): array + { + return $this->requiredPermissions; + } + + public function canViewMenu(?object $user, ?object $workspace): bool + { + return $this->canView; + } + }; +} + +/** + * Create a mock user object with permission checking. + */ +function createMockUser(int $id = 1, array $allowedPermissions = []): object +{ + return new class($id, $allowedPermissions) + { + public function __construct( + public int $id, + private array $allowedPermissions + ) {} + + public function can(string $permission, mixed $resource = null): bool + { + return in_array($permission, $this->allowedPermissions, true); + } + + public function hasPermission(string $permission): bool + { + return $this->can($permission); + } + }; +} + +/** + * Create a mock workspace object. + */ +function createMockWorkspace(int $id = 1, string $slug = 'test-workspace'): object +{ + return new class($id, $slug) + { + public function __construct( + public int $id, + public string $slug + ) {} + }; +} + +/** + * Create a fresh registry instance for testing. + */ +function createRegistry(): AdminMenuRegistry +{ + $registry = new AdminMenuRegistry(null, new IconValidator); + $registry->setCachingEnabled(false); + + return $registry; +} + +// ============================================================================= +// AdminMenuRegistry Tests +// ============================================================================= + +describe('AdminMenuRegistry', function () { + describe('provider registration', function () { + it('returns empty array when no providers registered', function () { + $registry = createRegistry(); + $menu = $registry->build(null); + + expect($menu)->toBeArray() + ->and($menu)->toBeEmpty(); + }); + + it('registers single provider', function () { + $registry = createRegistry(); + $provider = createMockProvider([ + [ + 'group' => 'services', + 'priority' => 10, + 'item' => fn () => ['label' => 'Test Service', 'icon' => 'cog', 'href' => '/test'], + ], + ]); + + $registry->register($provider); + $menu = $registry->build(null); + + expect($menu)->not->toBeEmpty(); + }); + + it('registers multiple providers', function () { + $registry = createRegistry(); + + $provider1 = createMockProvider([ + [ + 'group' => 'dashboard', + 'priority' => 10, + 'item' => fn () => ['label' => 'Provider 1', 'icon' => 'home', 'href' => '/one'], + ], + ]); + + $provider2 = createMockProvider([ + [ + 'group' => 'dashboard', + 'priority' => 20, + 'item' => fn () => ['label' => 'Provider 2', 'icon' => 'star', 'href' => '/two'], + ], + ]); + + $registry->register($provider1); + $registry->register($provider2); + + $menu = $registry->build(null); + $labels = array_column($menu, 'label'); + + expect($labels)->toContain('Provider 1') + ->and($labels)->toContain('Provider 2'); + }); + }); + + describe('menu structure', function () { + it('returns predefined group keys', function () { + $registry = createRegistry(); + $groups = $registry->getGroups(); + + expect($groups)->toBeArray() + ->and($groups)->toContain('dashboard') + ->and($groups)->toContain('workspaces') + ->and($groups)->toContain('services') + ->and($groups)->toContain('settings') + ->and($groups)->toContain('admin'); + }); + + it('returns group configuration for known groups', function () { + $registry = createRegistry(); + $config = $registry->getGroupConfig('settings'); + + expect($config)->toBeArray() + ->and($config)->toHaveKey('label') + ->and($config['label'])->toBe('Account'); + }); + + it('returns empty array for unknown groups', function () { + $registry = createRegistry(); + $config = $registry->getGroupConfig('nonexistent'); + + expect($config)->toBeArray() + ->and($config)->toBeEmpty(); + }); + + it('sorts items by priority within group', function () { + $registry = createRegistry(); + $provider = createMockProvider([ + ['group' => 'dashboard', 'priority' => 30, 'item' => fn () => ['label' => 'Third', 'icon' => 'cog', 'href' => '/third']], + ['group' => 'dashboard', 'priority' => 10, 'item' => fn () => ['label' => 'First', 'icon' => 'home', 'href' => '/first']], + ['group' => 'dashboard', 'priority' => 20, 'item' => fn () => ['label' => 'Second', 'icon' => 'star', 'href' => '/second']], + ]); + + $registry->register($provider); + $menu = $registry->build(null); + + $labels = array_column($menu, 'label'); + expect($labels)->toBe(['First', 'Second', 'Third']); + }); + + it('uses default priority 50 when not specified', function () { + $registry = createRegistry(); + $provider = createMockProvider([ + ['group' => 'dashboard', 'priority' => 100, 'item' => fn () => ['label' => 'Low', 'icon' => 'down', 'href' => '/low']], + ['group' => 'dashboard', 'item' => fn () => ['label' => 'Default', 'icon' => 'minus', 'href' => '/default']], + ['group' => 'dashboard', 'priority' => 10, 'item' => fn () => ['label' => 'High', 'icon' => 'up', 'href' => '/high']], + ]); + + $registry->register($provider); + $menu = $registry->build(null); + + $labels = array_column($menu, 'label'); + expect($labels)->toBe(['High', 'Default', 'Low']); + }); + + it('adds dividers between different groups', function () { + $registry = createRegistry(); + $provider = createMockProvider([ + ['group' => 'dashboard', 'priority' => 10, 'item' => fn () => ['label' => 'Dashboard Item', 'icon' => 'home', 'href' => '/']], + ['group' => 'services', 'priority' => 10, 'item' => fn () => ['label' => 'Service Item', 'icon' => 'cog', 'href' => '/service']], + ]); + + $registry->register($provider); + $menu = $registry->build(null); + + $hasDivider = collect($menu)->contains(fn ($item) => isset($item['divider']) && $item['divider'] === true); + expect($hasDivider)->toBeTrue(); + }); + + it('creates dropdown for non-standalone groups', function () { + $registry = createRegistry(); + $provider = createMockProvider([ + ['group' => 'settings', 'priority' => 10, 'item' => fn () => ['label' => 'Profile', 'icon' => 'user', 'href' => '/profile']], + ['group' => 'settings', 'priority' => 20, 'item' => fn () => ['label' => 'Security', 'icon' => 'lock', 'href' => '/security']], + ]); + + $registry->register($provider); + $menu = $registry->build(null); + + $settingsDropdown = collect($menu)->first(fn ($item) => ($item['label'] ?? null) === 'Account'); + + expect($settingsDropdown)->not->toBeNull() + ->and($settingsDropdown)->toHaveKey('children') + ->and($settingsDropdown['children'])->toHaveCount(2); + }); + + it('skips items returning null from closure', function () { + $registry = createRegistry(); + $provider = createMockProvider([ + ['group' => 'dashboard', 'priority' => 10, 'item' => fn () => ['label' => 'Visible', 'icon' => 'eye', 'href' => '/visible']], + ['group' => 'dashboard', 'priority' => 20, 'item' => fn () => null], + ]); + + $registry->register($provider); + $menu = $registry->build(null); + + expect($menu)->toHaveCount(1) + ->and($menu[0]['label'])->toBe('Visible'); + }); + }); + + describe('authorization', function () { + it('skips items requiring admin when user is not admin', function () { + $registry = createRegistry(); + $provider = createMockProvider([ + ['group' => 'dashboard', 'priority' => 10, 'item' => fn () => ['label' => 'Public', 'icon' => 'globe', 'href' => '/public']], + ['group' => 'dashboard', 'priority' => 20, 'admin' => true, 'item' => fn () => ['label' => 'Admin Only', 'icon' => 'shield', 'href' => '/admin']], + ]); + + $registry->register($provider); + $menu = $registry->build(null, isAdmin: false); + + $labels = array_column($menu, 'label'); + expect($labels)->toContain('Public') + ->and($labels)->not->toContain('Admin Only'); + }); + + it('includes admin items when user is admin', function () { + $registry = createRegistry(); + $workspace = createMockWorkspace(1, 'system'); + $provider = createMockProvider([ + ['group' => 'admin', 'priority' => 10, 'admin' => true, 'item' => fn () => ['label' => 'Admin Panel', 'icon' => 'crown', 'href' => '/admin']], + ]); + + $registry->register($provider); + $menu = $registry->build($workspace, isAdmin: true); + + // Admin group becomes a dropdown + $adminDropdown = collect($menu)->first(fn ($item) => ($item['label'] ?? null) === 'Admin'); + + expect($adminDropdown)->not->toBeNull() + ->and($adminDropdown['children'])->toHaveCount(1); + }); + + it('respects provider-level permissions', function () { + $registry = createRegistry(); + $user = createMockUser(1, []); + + // Provider that denies menu viewing + $provider = createMockProvider( + items: [['group' => 'dashboard', 'priority' => 10, 'item' => fn () => ['label' => 'Hidden', 'icon' => 'lock', 'href' => '/hidden']]], + permissions: [], + canView: false + ); + + $registry->register($provider); + $menu = $registry->build(null, isAdmin: false, user: $user); + + expect($menu)->toBeEmpty(); + }); + + it('respects item-level permissions', function () { + $registry = createRegistry(); + $user = createMockUser(1, ['view.public']); // Only has public permission + + $provider = createMockProvider([ + [ + 'group' => 'dashboard', + 'priority' => 10, + 'permissions' => ['view.public'], + 'item' => fn () => ['label' => 'Public Page', 'icon' => 'globe', 'href' => '/public'], + ], + [ + 'group' => 'dashboard', + 'priority' => 20, + 'permissions' => ['view.secret'], + 'item' => fn () => ['label' => 'Secret Page', 'icon' => 'lock', 'href' => '/secret'], + ], + ]); + + $registry->register($provider); + $menu = $registry->build(null, isAdmin: false, user: $user); + + $labels = array_column($menu, 'label'); + expect($labels)->toContain('Public Page') + ->and($labels)->not->toContain('Secret Page'); + }); + }); +}); + +// ============================================================================= +// MenuItemBuilder Tests +// ============================================================================= + +describe('MenuItemBuilder', function () { + describe('basic construction', function () { + it('creates item with label', function () { + $builder = MenuItemBuilder::make('Dashboard'); + + expect($builder->getLabel())->toBe('Dashboard'); + }); + + it('creates item with icon', function () { + $item = MenuItemBuilder::make('Dashboard') + ->icon('home') + ->href('/') + ->build(); + + $evaluated = ($item['item'])(); + expect($evaluated['icon'])->toBe('home'); + }); + + it('creates item with href', function () { + $item = MenuItemBuilder::make('Dashboard') + ->href('/dashboard') + ->build(); + + $evaluated = ($item['item'])(); + expect($evaluated['href'])->toBe('/dashboard'); + }); + + it('defaults href to # when not specified', function () { + $item = MenuItemBuilder::make('Dashboard') + ->build(); + + $evaluated = ($item['item'])(); + expect($evaluated['href'])->toBe('#'); + }); + }); + + describe('groups', function () { + it('defaults to services group', function () { + $item = MenuItemBuilder::make('Test') + ->build(); + + expect($item['group'])->toBe('services'); + }); + + it('sets group with inGroup()', function () { + $item = MenuItemBuilder::make('Test') + ->inGroup('settings') + ->build(); + + expect($item['group'])->toBe('settings'); + }); + + it('sets dashboard group with inDashboard()', function () { + $item = MenuItemBuilder::make('Test') + ->inDashboard() + ->build(); + + expect($item['group'])->toBe('dashboard'); + }); + + it('sets workspaces group with inWorkspaces()', function () { + $item = MenuItemBuilder::make('Test') + ->inWorkspaces() + ->build(); + + expect($item['group'])->toBe('workspaces'); + }); + + it('sets settings group with inSettings()', function () { + $item = MenuItemBuilder::make('Test') + ->inSettings() + ->build(); + + expect($item['group'])->toBe('settings'); + }); + + it('sets admin group with inAdmin()', function () { + $item = MenuItemBuilder::make('Test') + ->inAdmin() + ->build(); + + expect($item['group'])->toBe('admin'); + }); + }); + + describe('priority', function () { + it('defaults to PRIORITY_NORMAL (50)', function () { + $item = MenuItemBuilder::make('Test') + ->build(); + + expect($item['priority'])->toBe(AdminMenuProvider::PRIORITY_NORMAL); + }); + + it('sets priority with withPriority()', function () { + $item = MenuItemBuilder::make('Test') + ->withPriority(AdminMenuProvider::PRIORITY_HIGH) + ->build(); + + expect($item['priority'])->toBe(AdminMenuProvider::PRIORITY_HIGH); + }); + + it('sets priority with priority() alias', function () { + $item = MenuItemBuilder::make('Test') + ->priority(25) + ->build(); + + expect($item['priority'])->toBe(25); + }); + + it('sets highest priority with first()', function () { + $item = MenuItemBuilder::make('Test') + ->first() + ->build(); + + expect($item['priority'])->toBe(AdminMenuProvider::PRIORITY_FIRST); + }); + + it('sets high priority with high()', function () { + $item = MenuItemBuilder::make('Test') + ->high() + ->build(); + + expect($item['priority'])->toBe(AdminMenuProvider::PRIORITY_HIGH); + }); + + it('sets low priority with low()', function () { + $item = MenuItemBuilder::make('Test') + ->low() + ->build(); + + expect($item['priority'])->toBe(AdminMenuProvider::PRIORITY_LOW); + }); + + it('sets lowest priority with last()', function () { + $item = MenuItemBuilder::make('Test') + ->last() + ->build(); + + expect($item['priority'])->toBe(AdminMenuProvider::PRIORITY_LAST); + }); + }); + + describe('badges', function () { + it('sets text badge', function () { + $item = MenuItemBuilder::make('Messages') + ->badge('New') + ->build(); + + $evaluated = ($item['item'])(); + expect($evaluated['badge'])->toBe('New'); + }); + + it('sets badge with colour', function () { + $item = MenuItemBuilder::make('Messages') + ->badge('3', 'red') + ->build(); + + $evaluated = ($item['item'])(); + expect($evaluated['badge'])->toBe(['text' => '3', 'color' => 'red']); + }); + + it('sets numeric badge with badgeCount()', function () { + $item = MenuItemBuilder::make('Notifications') + ->badgeCount(42) + ->build(); + + $evaluated = ($item['item'])(); + expect($evaluated['badge'])->toBe('42'); + }); + + it('sets badge config with badgeConfig()', function () { + $item = MenuItemBuilder::make('Tasks') + ->badgeConfig(['text' => '5', 'color' => 'amber', 'tooltip' => 'Pending tasks']) + ->build(); + + $evaluated = ($item['item'])(); + expect($evaluated['badge'])->toBe(['text' => '5', 'color' => 'amber', 'tooltip' => 'Pending tasks']); + }); + }); + + describe('colour', function () { + it('sets colour theme', function () { + $item = MenuItemBuilder::make('Settings') + ->color('blue') + ->build(); + + $evaluated = ($item['item'])(); + expect($evaluated['color'])->toBe('blue'); + }); + }); + + describe('authorization', function () { + it('sets entitlement requirement', function () { + $item = MenuItemBuilder::make('Commerce') + ->entitlement('core.srv.commerce') + ->build(); + + expect($item['entitlement'])->toBe('core.srv.commerce'); + }); + + it('sets entitlement with requiresEntitlement() alias', function () { + $item = MenuItemBuilder::make('Bio') + ->requiresEntitlement('core.srv.bio') + ->build(); + + expect($item['entitlement'])->toBe('core.srv.bio'); + }); + + it('sets permissions array', function () { + $item = MenuItemBuilder::make('Users') + ->permissions(['users.view', 'users.edit']) + ->build(); + + expect($item['permissions'])->toBe(['users.view', 'users.edit']); + }); + + it('adds single permission', function () { + $item = MenuItemBuilder::make('Posts') + ->permission('posts.view') + ->permission('posts.create') + ->build(); + + expect($item['permissions'])->toBe(['posts.view', 'posts.create']); + }); + + it('sets admin requirement', function () { + $item = MenuItemBuilder::make('Platform') + ->requireAdmin() + ->build(); + + expect($item['admin'])->toBeTrue(); + }); + + it('sets admin requirement with adminOnly() alias', function () { + $item = MenuItemBuilder::make('System') + ->adminOnly() + ->build(); + + expect($item['admin'])->toBeTrue(); + }); + }); + + describe('active state', function () { + it('sets active state explicitly', function () { + $item = MenuItemBuilder::make('Current Page') + ->active(true) + ->build(); + + $evaluated = ($item['item'])(); + expect($evaluated['active'])->toBeTrue(); + }); + + it('defaults active to false', function () { + $item = MenuItemBuilder::make('Other Page') + ->build(); + + $evaluated = ($item['item'])(); + expect($evaluated['active'])->toBeFalse(); + }); + + it('evaluates active callback', function () { + $item = MenuItemBuilder::make('Dynamic') + ->activeWhen(fn () => true) + ->build(); + + $evaluated = ($item['item'])(); + expect($evaluated['active'])->toBeTrue(); + }); + }); + + describe('children', function () { + it('sets children array', function () { + $item = MenuItemBuilder::make('Parent') + ->children([ + MenuItemBuilder::child('Child 1', '/child-1'), + MenuItemBuilder::child('Child 2', '/child-2'), + ]) + ->build(); + + $evaluated = ($item['item'])(); + expect($evaluated['children'])->toHaveCount(2); + }); + + it('adds single child', function () { + $item = MenuItemBuilder::make('Parent') + ->addChild(MenuItemBuilder::child('Child', '/child')) + ->build(); + + $evaluated = ($item['item'])(); + expect($evaluated['children'])->toHaveCount(1); + }); + + it('adds separator to children', function () { + $item = MenuItemBuilder::make('Parent') + ->addChild(MenuItemBuilder::child('Child 1', '/child-1')) + ->separator() + ->addChild(MenuItemBuilder::child('Child 2', '/child-2')) + ->build(); + + $evaluated = ($item['item'])(); + expect($evaluated['children'])->toHaveCount(3) + ->and($evaluated['children'][1])->toBe(['separator' => true]); + }); + + it('adds section header to children', function () { + $item = MenuItemBuilder::make('Parent') + ->section('Products', 'cube') + ->addChild(MenuItemBuilder::child('All Products', '/products')) + ->build(); + + $evaluated = ($item['item'])(); + expect($evaluated['children'][0])->toBe(['section' => 'Products', 'icon' => 'cube']); + }); + + it('adds divider to children', function () { + $item = MenuItemBuilder::make('Parent') + ->addChild(MenuItemBuilder::child('Child 1', '/child-1')) + ->divider('More') + ->addChild(MenuItemBuilder::child('Child 2', '/child-2')) + ->build(); + + $evaluated = ($item['item'])(); + expect($evaluated['children'][1])->toBe(['divider' => true, 'label' => 'More']); + }); + + it('creates child item with child() factory', function () { + $child = MenuItemBuilder::child('Products', '/products') + ->icon('cube') + ->active(true) + ->buildChildItem(); + + expect($child['label'])->toBe('Products') + ->and($child['href'])->toBe('/products') + ->and($child['icon'])->toBe('cube') + ->and($child['active'])->toBeTrue(); + }); + }); + + describe('service key', function () { + it('sets service key', function () { + $item = MenuItemBuilder::make('Commerce') + ->service('commerce') + ->build(); + + expect($item['service'])->toBe('commerce'); + }); + }); + + describe('custom attributes', function () { + it('sets single custom attribute', function () { + $item = MenuItemBuilder::make('Test') + ->with('data-testid', 'menu-item') + ->build(); + + $evaluated = ($item['item'])(); + expect($evaluated['data-testid'])->toBe('menu-item'); + }); + + it('sets multiple custom attributes', function () { + $item = MenuItemBuilder::make('Test') + ->withAttributes(['data-foo' => 'bar', 'data-baz' => 'qux']) + ->build(); + + $evaluated = ($item['item'])(); + expect($evaluated['data-foo'])->toBe('bar') + ->and($evaluated['data-baz'])->toBe('qux'); + }); + }); +}); + +// ============================================================================= +// MenuItemGroup Tests +// ============================================================================= + +describe('MenuItemGroup', function () { + it('creates separator', function () { + $separator = MenuItemGroup::separator(); + + expect($separator)->toBe(['separator' => true]); + }); + + it('creates header with label only', function () { + $header = MenuItemGroup::header('Products'); + + expect($header)->toBe(['section' => 'Products']); + }); + + it('creates header with icon', function () { + $header = MenuItemGroup::header('Products', 'cube'); + + expect($header)->toBe(['section' => 'Products', 'icon' => 'cube']); + }); + + it('creates header with colour', function () { + $header = MenuItemGroup::header('Orders', 'receipt', 'blue'); + + expect($header)->toBe(['section' => 'Orders', 'icon' => 'receipt', 'color' => 'blue']); + }); + + it('creates header with badge', function () { + $header = MenuItemGroup::header('Tasks', 'check', null, '5'); + + expect($header)->toBe(['section' => 'Tasks', 'icon' => 'check', 'badge' => '5']); + }); + + it('creates divider without label', function () { + $divider = MenuItemGroup::divider(); + + expect($divider)->toBe(['divider' => true]); + }); + + it('creates divider with label', function () { + $divider = MenuItemGroup::divider('More Options'); + + expect($divider)->toBe(['divider' => true, 'label' => 'More Options']); + }); + + it('creates collapsible group', function () { + $children = [ + ['label' => 'Item 1', 'href' => '/item-1'], + ['label' => 'Item 2', 'href' => '/item-2'], + ]; + + $collapsible = MenuItemGroup::collapsible('Advanced', $children, 'gear', 'slate', false); + + expect($collapsible['collapsible'])->toBeTrue() + ->and($collapsible['label'])->toBe('Advanced') + ->and($collapsible['children'])->toBe($children) + ->and($collapsible['icon'])->toBe('gear') + ->and($collapsible['color'])->toBe('slate') + ->and($collapsible['open'])->toBeFalse(); + }); + + it('creates collapsible with state persistence', function () { + $collapsible = MenuItemGroup::collapsible('Settings', [], null, null, true, 'menu.settings.open'); + + expect($collapsible['stateKey'])->toBe('menu.settings.open'); + }); + + describe('type detection', function () { + it('detects separator', function () { + expect(MenuItemGroup::isSeparator(['separator' => true]))->toBeTrue() + ->and(MenuItemGroup::isSeparator(['label' => 'Test']))->toBeFalse(); + }); + + it('detects header', function () { + expect(MenuItemGroup::isHeader(['section' => 'Products']))->toBeTrue() + ->and(MenuItemGroup::isHeader(['label' => 'Test']))->toBeFalse(); + }); + + it('detects collapsible', function () { + expect(MenuItemGroup::isCollapsible(['collapsible' => true]))->toBeTrue() + ->and(MenuItemGroup::isCollapsible(['label' => 'Test']))->toBeFalse(); + }); + + it('detects divider', function () { + expect(MenuItemGroup::isDivider(['divider' => true]))->toBeTrue() + ->and(MenuItemGroup::isDivider(['label' => 'Test']))->toBeFalse(); + }); + + it('detects structural elements', function () { + expect(MenuItemGroup::isStructural(['separator' => true]))->toBeTrue() + ->and(MenuItemGroup::isStructural(['section' => 'Test']))->toBeTrue() + ->and(MenuItemGroup::isStructural(['divider' => true]))->toBeTrue() + ->and(MenuItemGroup::isStructural(['collapsible' => true]))->toBeTrue() + ->and(MenuItemGroup::isStructural(['label' => 'Test']))->toBeFalse(); + }); + + it('detects links', function () { + expect(MenuItemGroup::isLink(['label' => 'Test', 'href' => '/test']))->toBeTrue() + ->and(MenuItemGroup::isLink(['separator' => true]))->toBeFalse() + ->and(MenuItemGroup::isLink(['section' => 'Test']))->toBeFalse(); + }); + }); +}); + +// ============================================================================= +// IconValidator Tests +// ============================================================================= + +describe('IconValidator', function () { + describe('validation', function () { + it('validates known solid icons', function () { + $validator = new IconValidator; + + expect($validator->isValid('home'))->toBeTrue() + ->and($validator->isValid('user'))->toBeTrue() + ->and($validator->isValid('gear'))->toBeTrue() + ->and($validator->isValid('cog'))->toBeTrue(); + }); + + it('validates known brand icons', function () { + $validator = new IconValidator; + + expect($validator->isValid('github'))->toBeTrue() + ->and($validator->isValid('twitter'))->toBeTrue() + ->and($validator->isValid('facebook'))->toBeTrue(); + }); + + it('normalises full FontAwesome class names', function () { + $validator = new IconValidator; + + expect($validator->isValid('fas fa-home'))->toBeTrue() + ->and($validator->isValid('fa-solid fa-user'))->toBeTrue() + ->and($validator->isValid('fab fa-github'))->toBeTrue() + ->and($validator->isValid('fa-brands fa-twitter'))->toBeTrue(); + }); + + it('normalises fa- prefix', function () { + $validator = new IconValidator; + + expect($validator->normalizeIcon('fa-home'))->toBe('home') + ->and($validator->normalizeIcon('fa-user'))->toBe('user'); + }); + + it('handles case insensitivity', function () { + $validator = new IconValidator; + + expect($validator->normalizeIcon('HOME'))->toBe('home') + ->and($validator->normalizeIcon('User'))->toBe('user'); + }); + + it('returns errors for empty icon', function () { + $validator = new IconValidator; + $errors = $validator->validate(''); + + expect($errors)->not->toBeEmpty() + ->and($errors[0])->toBe('Icon name cannot be empty'); + }); + + it('validates multiple icons at once', function () { + $validator = new IconValidator; + $validator->setStrictMode(true); + + $results = $validator->validateMany(['home', 'invalid-xyz-icon', 'user']); + + expect($results)->toHaveKey('invalid-xyz-icon') + ->and($results)->not->toHaveKey('home') + ->and($results)->not->toHaveKey('user'); + }); + }); + + describe('custom icons', function () { + it('allows adding custom icons', function () { + $validator = new IconValidator; + $validator->setStrictMode(true); + $validator->addCustomIcon('my-custom-icon'); + + expect($validator->isValid('my-custom-icon'))->toBeTrue(); + }); + + it('allows adding multiple custom icons', function () { + $validator = new IconValidator; + $validator->setStrictMode(true); + $validator->addCustomIcons(['icon-one', 'icon-two']); + + expect($validator->isValid('icon-one'))->toBeTrue() + ->and($validator->isValid('icon-two'))->toBeTrue(); + }); + + it('returns custom icons', function () { + $validator = new IconValidator; + $validator->addCustomIcon('custom-test'); + + expect($validator->getCustomIcons())->toContain('custom-test'); + }); + }); + + describe('icon packs', function () { + it('allows registering icon packs', function () { + $validator = new IconValidator; + $validator->setStrictMode(true); + $validator->registerIconPack('mypack', ['pack-icon-1', 'pack-icon-2']); + + expect($validator->isValid('pack-icon-1'))->toBeTrue() + ->and($validator->isValid('pack-icon-2'))->toBeTrue(); + }); + }); + + describe('suggestions', function () { + it('suggests similar icons for typos', function () { + $validator = new IconValidator; + $suggestions = $validator->getSuggestions('hone', 3); // typo for 'home' + + expect($suggestions)->toContain('home'); + }); + + it('limits number of suggestions', function () { + $validator = new IconValidator; + $suggestions = $validator->getSuggestions('us', 3); + + expect(count($suggestions))->toBeLessThanOrEqual(3); + }); + }); + + describe('icon lists', function () { + it('returns solid icons', function () { + $validator = new IconValidator; + $icons = $validator->getSolidIcons(); + + expect($icons)->toBeArray() + ->and($icons)->toContain('home') + ->and($icons)->toContain('user') + ->and($icons)->toContain('gear'); + }); + + it('returns brand icons', function () { + $validator = new IconValidator; + $icons = $validator->getBrandIcons(); + + expect($icons)->toBeArray() + ->and($icons)->toContain('github') + ->and($icons)->toContain('twitter'); + }); + }); + + describe('strict mode', function () { + it('allows unknown icons in non-strict mode (default)', function () { + $validator = new IconValidator; + $validator->setStrictMode(false); + + expect($validator->isValid('completely-unknown-icon'))->toBeTrue(); + }); + + it('rejects unknown icons in strict mode', function () { + $validator = new IconValidator; + $validator->setStrictMode(true); + + expect($validator->isValid('completely-unknown-icon'))->toBeFalse(); + }); + }); +}); + +// ============================================================================= +// Integration Tests +// ============================================================================= + +describe('Admin Menu System Integration', function () { + it('builds complete menu with multiple providers using MenuItemBuilder', function () { + $registry = createRegistry(); + + // Provider 1: Dashboard items + $dashboardItems = [ + MenuItemBuilder::make('Dashboard') + ->icon('home') + ->href('/dashboard') + ->inDashboard() + ->first() + ->active(true) + ->build(), + ]; + + // Provider 2: Service items with badges + $serviceItems = [ + MenuItemBuilder::make('Commerce') + ->icon('cart-shopping') + ->href('/commerce') + ->inServices() + ->entitlement('core.srv.commerce') + ->badge('3', 'red') + ->children([ + MenuItemGroup::header('Products', 'cube'), + MenuItemBuilder::child('All Products', '/commerce/products')->icon('list'), + MenuItemBuilder::child('Categories', '/commerce/categories')->icon('folder'), + MenuItemGroup::separator(), + MenuItemGroup::header('Orders', 'receipt'), + MenuItemBuilder::child('All Orders', '/commerce/orders')->icon('file-lines'), + ]) + ->build(), + ]; + + // Provider 3: Settings items + $settingsItems = [ + MenuItemBuilder::make('Profile') + ->icon('user') + ->href('/profile') + ->inSettings() + ->priority(10) + ->build(), + MenuItemBuilder::make('Security') + ->icon('lock') + ->href('/security') + ->inSettings() + ->priority(20) + ->permissions(['settings.security']) + ->build(), + ]; + + $registry->register(createMockProvider($dashboardItems)); + $registry->register(createMockProvider($serviceItems)); + $registry->register(createMockProvider($settingsItems)); + + $user = createMockUser(1, ['settings.security']); + $menu = $registry->build(null, isAdmin: false, user: $user); + + // Verify structure + expect($menu)->not->toBeEmpty(); + + // Dashboard should be first (standalone group) + expect($menu[0]['label'])->toBe('Dashboard') + ->and($menu[0]['active'])->toBeTrue(); + + // Should have dividers between groups + $dividers = collect($menu)->filter(fn ($item) => isset($item['divider'])); + expect($dividers)->not->toBeEmpty(); + + // Settings should be a dropdown with children + $settingsDropdown = collect($menu)->first(fn ($item) => ($item['label'] ?? null) === 'Account'); + expect($settingsDropdown)->not->toBeNull() + ->and($settingsDropdown['children'])->toHaveCount(2); + }); +});