diff --git a/tests/Feature/AgentApiKeyServiceTest.php b/tests/Feature/AgentApiKeyServiceTest.php index 0520af8..484d8de 100644 --- a/tests/Feature/AgentApiKeyServiceTest.php +++ b/tests/Feature/AgentApiKeyServiceTest.php @@ -2,144 +2,119 @@ declare(strict_types=1); -namespace Core\Mod\Agentic\Tests\Feature; - -use Carbon\Carbon; -use Core\Mod\Agentic\Models\AgentApiKey; -use Core\Mod\Agentic\Services\AgentApiKeyService; -use Core\Tenant\Models\Workspace; -use Illuminate\Foundation\Testing\RefreshDatabase; -use Illuminate\Support\Facades\Cache; -use Tests\TestCase; - /** * Tests for the AgentApiKeyService. * * Covers authentication, IP validation, rate limit tracking, and key management. */ -class AgentApiKeyServiceTest extends TestCase -{ - use RefreshDatabase; - private Workspace $workspace; +use Carbon\Carbon; +use Core\Mod\Agentic\Models\AgentApiKey; +use Core\Mod\Agentic\Services\AgentApiKeyService; +use Illuminate\Support\Facades\Cache; - private AgentApiKeyService $service; +// ========================================================================= +// Key Creation Tests +// ========================================================================= - protected function setUp(): void - { - parent::setUp(); - $this->workspace = Workspace::factory()->create(); - $this->service = app(AgentApiKeyService::class); - } +describe('key creation', function () { + it('creates and returns an AgentApiKey instance', function () { + $workspace = createWorkspace(); + $service = app(AgentApiKeyService::class); - // ========================================================================= - // Key Creation Tests - // ========================================================================= + $key = $service->create($workspace, 'Test Key'); - public function test_create_returns_agent_api_key(): void - { - $key = $this->service->create( - $this->workspace, - 'Test Key' - ); + expect($key) + ->toBeInstanceOf(AgentApiKey::class) + ->and($key->plainTextKey)->not->toBeNull(); + }); - $this->assertInstanceOf(AgentApiKey::class, $key); - $this->assertNotNull($key->plainTextKey); - } + it('creates key using workspace ID', function () { + $workspace = createWorkspace(); + $service = app(AgentApiKeyService::class); - public function test_create_with_workspace_id(): void - { - $key = $this->service->create( - $this->workspace->id, - 'Test Key' - ); + $key = $service->create($workspace->id, 'Test Key'); - $this->assertEquals($this->workspace->id, $key->workspace_id); - } + expect($key->workspace_id)->toBe($workspace->id); + }); - public function test_create_with_permissions(): void - { + it('creates key with specified permissions', function () { + $workspace = createWorkspace(); + $service = app(AgentApiKeyService::class); $permissions = [ AgentApiKey::PERM_PLANS_READ, AgentApiKey::PERM_PLANS_WRITE, ]; - $key = $this->service->create( - $this->workspace, - 'Test Key', - $permissions - ); + $key = $service->create($workspace, 'Test Key', $permissions); - $this->assertEquals($permissions, $key->permissions); - } + expect($key->permissions)->toBe($permissions); + }); - public function test_create_with_custom_rate_limit(): void - { - $key = $this->service->create( - $this->workspace, - 'Test Key', - [], - 500 - ); + it('creates key with custom rate limit', function () { + $workspace = createWorkspace(); + $service = app(AgentApiKeyService::class); - $this->assertEquals(500, $key->rate_limit); - } + $key = $service->create($workspace, 'Test Key', [], 500); - public function test_create_with_expiry(): void - { + expect($key->rate_limit)->toBe(500); + }); + + it('creates key with expiry date', function () { + $workspace = createWorkspace(); + $service = app(AgentApiKeyService::class); $expiresAt = Carbon::now()->addMonth(); - $key = $this->service->create( - $this->workspace, - 'Test Key', - [], - 100, - $expiresAt - ); + $key = $service->create($workspace, 'Test Key', [], 100, $expiresAt); - $this->assertEquals( - $expiresAt->toDateTimeString(), - $key->expires_at->toDateTimeString() - ); - } + expect($key->expires_at->toDateTimeString()) + ->toBe($expiresAt->toDateTimeString()); + }); +}); - // ========================================================================= - // Key Validation Tests - // ========================================================================= +// ========================================================================= +// Key Validation Tests +// ========================================================================= - public function test_validate_returns_key_for_valid_key(): void - { - $key = $this->service->create($this->workspace, 'Test Key'); +describe('key validation', function () { + it('returns key for valid plaintext key', function () { + $workspace = createWorkspace(); + $service = app(AgentApiKeyService::class); + $key = $service->create($workspace, 'Test Key'); $plainKey = $key->plainTextKey; - $result = $this->service->validate($plainKey); + $result = $service->validate($plainKey); - $this->assertNotNull($result); - $this->assertEquals($key->id, $result->id); - } + expect($result) + ->not->toBeNull() + ->and($result->id)->toBe($key->id); + }); - public function test_validate_returns_null_for_invalid_key(): void - { - $result = $this->service->validate('ak_invalid_key_here'); + it('returns null for invalid key', function () { + $service = app(AgentApiKeyService::class); - $this->assertNull($result); - } + $result = $service->validate('ak_invalid_key_here'); - public function test_validate_returns_null_for_revoked_key(): void - { - $key = $this->service->create($this->workspace, 'Test Key'); + expect($result)->toBeNull(); + }); + + it('returns null for revoked key', function () { + $workspace = createWorkspace(); + $service = app(AgentApiKeyService::class); + $key = $service->create($workspace, 'Test Key'); $plainKey = $key->plainTextKey; $key->revoke(); - $result = $this->service->validate($plainKey); + $result = $service->validate($plainKey); - $this->assertNull($result); - } + expect($result)->toBeNull(); + }); - public function test_validate_returns_null_for_expired_key(): void - { - $key = $this->service->create( - $this->workspace, + it('returns null for expired key', function () { + $workspace = createWorkspace(); + $service = app(AgentApiKeyService::class); + $key = $service->create( + $workspace, 'Test Key', [], 100, @@ -147,445 +122,478 @@ class AgentApiKeyServiceTest extends TestCase ); $plainKey = $key->plainTextKey; - $result = $this->service->validate($plainKey); + $result = $service->validate($plainKey); - $this->assertNull($result); - } + expect($result)->toBeNull(); + }); +}); - // ========================================================================= - // Permission Check Tests - // ========================================================================= +// ========================================================================= +// Permission Check Tests +// ========================================================================= - public function test_check_permission_returns_true_when_granted(): void - { - $key = $this->service->create( - $this->workspace, +describe('permission checks', function () { + it('checkPermission returns true when permission granted', function () { + $workspace = createWorkspace(); + $service = app(AgentApiKeyService::class); + $key = $service->create( + $workspace, 'Test Key', [AgentApiKey::PERM_PLANS_READ] ); - $result = $this->service->checkPermission($key, AgentApiKey::PERM_PLANS_READ); + $result = $service->checkPermission($key, AgentApiKey::PERM_PLANS_READ); - $this->assertTrue($result); - } + expect($result)->toBeTrue(); + }); - public function test_check_permission_returns_false_when_not_granted(): void - { - $key = $this->service->create( - $this->workspace, + it('checkPermission returns false when permission not granted', function () { + $workspace = createWorkspace(); + $service = app(AgentApiKeyService::class); + $key = $service->create( + $workspace, 'Test Key', [AgentApiKey::PERM_PLANS_READ] ); - $result = $this->service->checkPermission($key, AgentApiKey::PERM_PLANS_WRITE); + $result = $service->checkPermission($key, AgentApiKey::PERM_PLANS_WRITE); - $this->assertFalse($result); - } + expect($result)->toBeFalse(); + }); - public function test_check_permission_returns_false_for_inactive_key(): void - { - $key = $this->service->create( - $this->workspace, + it('checkPermission returns false for inactive key', function () { + $workspace = createWorkspace(); + $service = app(AgentApiKeyService::class); + $key = $service->create( + $workspace, 'Test Key', [AgentApiKey::PERM_PLANS_READ] ); $key->revoke(); - $result = $this->service->checkPermission($key, AgentApiKey::PERM_PLANS_READ); + $result = $service->checkPermission($key, AgentApiKey::PERM_PLANS_READ); - $this->assertFalse($result); - } + expect($result)->toBeFalse(); + }); - public function test_check_permissions_returns_true_when_all_granted(): void - { - $key = $this->service->create( - $this->workspace, + it('checkPermissions returns true when all permissions granted', function () { + $workspace = createWorkspace(); + $service = app(AgentApiKeyService::class); + $key = $service->create( + $workspace, 'Test Key', [AgentApiKey::PERM_PLANS_READ, AgentApiKey::PERM_PLANS_WRITE] ); - $result = $this->service->checkPermissions($key, [ + $result = $service->checkPermissions($key, [ AgentApiKey::PERM_PLANS_READ, AgentApiKey::PERM_PLANS_WRITE, ]); - $this->assertTrue($result); - } + expect($result)->toBeTrue(); + }); - public function test_check_permissions_returns_false_when_missing_one(): void - { - $key = $this->service->create( - $this->workspace, + it('checkPermissions returns false when missing one permission', function () { + $workspace = createWorkspace(); + $service = app(AgentApiKeyService::class); + $key = $service->create( + $workspace, 'Test Key', [AgentApiKey::PERM_PLANS_READ] ); - $result = $this->service->checkPermissions($key, [ + $result = $service->checkPermissions($key, [ AgentApiKey::PERM_PLANS_READ, AgentApiKey::PERM_PLANS_WRITE, ]); - $this->assertFalse($result); - } + expect($result)->toBeFalse(); + }); +}); - // ========================================================================= - // Rate Limiting Tests - // ========================================================================= +// ========================================================================= +// Rate Limiting Tests +// ========================================================================= - public function test_record_usage_increments_cache_counter(): void - { - $key = $this->service->create($this->workspace, 'Test Key'); +describe('rate limiting', function () { + it('recordUsage increments cache counter', function () { + $workspace = createWorkspace(); + $service = app(AgentApiKeyService::class); + $key = $service->create($workspace, 'Test Key'); Cache::forget("agent_api_key_rate:{$key->id}"); - $this->service->recordUsage($key); - $this->service->recordUsage($key); - $this->service->recordUsage($key); + $service->recordUsage($key); + $service->recordUsage($key); + $service->recordUsage($key); - $this->assertEquals(3, Cache::get("agent_api_key_rate:{$key->id}")); - } + expect(Cache::get("agent_api_key_rate:{$key->id}"))->toBe(3); + }); - public function test_record_usage_records_client_ip(): void - { - $key = $this->service->create($this->workspace, 'Test Key'); + it('recordUsage records client IP', function () { + $workspace = createWorkspace(); + $service = app(AgentApiKeyService::class); + $key = $service->create($workspace, 'Test Key'); - $this->service->recordUsage($key, '192.168.1.100'); + $service->recordUsage($key, '192.168.1.100'); - $this->assertEquals('192.168.1.100', $key->fresh()->last_used_ip); - } + expect($key->fresh()->last_used_ip)->toBe('192.168.1.100'); + }); - public function test_record_usage_updates_last_used_at(): void - { - $key = $this->service->create($this->workspace, 'Test Key'); + it('recordUsage updates last_used_at', function () { + $workspace = createWorkspace(); + $service = app(AgentApiKeyService::class); + $key = $service->create($workspace, 'Test Key'); - $this->service->recordUsage($key); + $service->recordUsage($key); - $this->assertNotNull($key->fresh()->last_used_at); - } + expect($key->fresh()->last_used_at)->not->toBeNull(); + }); - public function test_is_rate_limited_returns_false_under_limit(): void - { - $key = $this->service->create($this->workspace, 'Test Key', [], 100); + it('isRateLimited returns false when under limit', function () { + $workspace = createWorkspace(); + $service = app(AgentApiKeyService::class); + $key = $service->create($workspace, 'Test Key', [], 100); Cache::put("agent_api_key_rate:{$key->id}", 50, 60); - $result = $this->service->isRateLimited($key); + $result = $service->isRateLimited($key); - $this->assertFalse($result); - } + expect($result)->toBeFalse(); + }); - public function test_is_rate_limited_returns_true_at_limit(): void - { - $key = $this->service->create($this->workspace, 'Test Key', [], 100); + it('isRateLimited returns true at limit', function () { + $workspace = createWorkspace(); + $service = app(AgentApiKeyService::class); + $key = $service->create($workspace, 'Test Key', [], 100); Cache::put("agent_api_key_rate:{$key->id}", 100, 60); - $result = $this->service->isRateLimited($key); + $result = $service->isRateLimited($key); - $this->assertTrue($result); - } + expect($result)->toBeTrue(); + }); - public function test_is_rate_limited_returns_true_over_limit(): void - { - $key = $this->service->create($this->workspace, 'Test Key', [], 100); + it('isRateLimited returns true over limit', function () { + $workspace = createWorkspace(); + $service = app(AgentApiKeyService::class); + $key = $service->create($workspace, 'Test Key', [], 100); Cache::put("agent_api_key_rate:{$key->id}", 150, 60); - $result = $this->service->isRateLimited($key); + $result = $service->isRateLimited($key); - $this->assertTrue($result); - } + expect($result)->toBeTrue(); + }); - public function test_get_rate_limit_status_returns_correct_values(): void - { - $key = $this->service->create($this->workspace, 'Test Key', [], 100); + it('getRateLimitStatus returns correct values', function () { + $workspace = createWorkspace(); + $service = app(AgentApiKeyService::class); + $key = $service->create($workspace, 'Test Key', [], 100); Cache::put("agent_api_key_rate:{$key->id}", 30, 60); - $status = $this->service->getRateLimitStatus($key); + $status = $service->getRateLimitStatus($key); - $this->assertEquals(100, $status['limit']); - $this->assertEquals(70, $status['remaining']); - $this->assertEquals(30, $status['used']); - $this->assertArrayHasKey('reset_in_seconds', $status); - } + expect($status['limit'])->toBe(100) + ->and($status['remaining'])->toBe(70) + ->and($status['used'])->toBe(30) + ->and($status)->toHaveKey('reset_in_seconds'); + }); +}); - // ========================================================================= - // Key Management Tests - // ========================================================================= +// ========================================================================= +// Key Management Tests +// ========================================================================= - public function test_revoke_sets_revoked_at(): void - { - $key = $this->service->create($this->workspace, 'Test Key'); +describe('key management', function () { + it('revoke sets revoked_at', function () { + $workspace = createWorkspace(); + $service = app(AgentApiKeyService::class); + $key = $service->create($workspace, 'Test Key'); - $this->service->revoke($key); + $service->revoke($key); - $this->assertNotNull($key->fresh()->revoked_at); - } + expect($key->fresh()->revoked_at)->not->toBeNull(); + }); - public function test_revoke_clears_rate_limit_cache(): void - { - $key = $this->service->create($this->workspace, 'Test Key'); + it('revoke clears rate limit cache', function () { + $workspace = createWorkspace(); + $service = app(AgentApiKeyService::class); + $key = $service->create($workspace, 'Test Key'); Cache::put("agent_api_key_rate:{$key->id}", 50, 60); - $this->service->revoke($key); + $service->revoke($key); - $this->assertNull(Cache::get("agent_api_key_rate:{$key->id}")); - } + expect(Cache::get("agent_api_key_rate:{$key->id}"))->toBeNull(); + }); - public function test_update_permissions_changes_permissions(): void - { - $key = $this->service->create( - $this->workspace, + it('updatePermissions changes permissions', function () { + $workspace = createWorkspace(); + $service = app(AgentApiKeyService::class); + $key = $service->create( + $workspace, 'Test Key', [AgentApiKey::PERM_PLANS_READ] ); - $this->service->updatePermissions($key, [AgentApiKey::PERM_SESSIONS_WRITE]); + $service->updatePermissions($key, [AgentApiKey::PERM_SESSIONS_WRITE]); $fresh = $key->fresh(); - $this->assertFalse($fresh->hasPermission(AgentApiKey::PERM_PLANS_READ)); - $this->assertTrue($fresh->hasPermission(AgentApiKey::PERM_SESSIONS_WRITE)); - } + expect($fresh->hasPermission(AgentApiKey::PERM_PLANS_READ))->toBeFalse() + ->and($fresh->hasPermission(AgentApiKey::PERM_SESSIONS_WRITE))->toBeTrue(); + }); - public function test_update_rate_limit_changes_limit(): void - { - $key = $this->service->create($this->workspace, 'Test Key', [], 100); + it('updateRateLimit changes limit', function () { + $workspace = createWorkspace(); + $service = app(AgentApiKeyService::class); + $key = $service->create($workspace, 'Test Key', [], 100); - $this->service->updateRateLimit($key, 500); + $service->updateRateLimit($key, 500); - $this->assertEquals(500, $key->fresh()->rate_limit); - } + expect($key->fresh()->rate_limit)->toBe(500); + }); - public function test_extend_expiry_updates_expiry(): void - { - $key = $this->service->create( - $this->workspace, + it('extendExpiry updates expiry date', function () { + $workspace = createWorkspace(); + $service = app(AgentApiKeyService::class); + $key = $service->create( + $workspace, 'Test Key', [], 100, Carbon::now()->addDay() ); - $newExpiry = Carbon::now()->addMonth(); - $this->service->extendExpiry($key, $newExpiry); - $this->assertEquals( - $newExpiry->toDateTimeString(), - $key->fresh()->expires_at->toDateTimeString() - ); - } + $service->extendExpiry($key, $newExpiry); - public function test_remove_expiry_clears_expiry(): void - { - $key = $this->service->create( - $this->workspace, + expect($key->fresh()->expires_at->toDateTimeString()) + ->toBe($newExpiry->toDateTimeString()); + }); + + it('removeExpiry clears expiry date', function () { + $workspace = createWorkspace(); + $service = app(AgentApiKeyService::class); + $key = $service->create( + $workspace, 'Test Key', [], 100, Carbon::now()->addDay() ); - $this->service->removeExpiry($key); + $service->removeExpiry($key); - $this->assertNull($key->fresh()->expires_at); - } + expect($key->fresh()->expires_at)->toBeNull(); + }); +}); - // ========================================================================= - // IP Restriction Tests - // ========================================================================= +// ========================================================================= +// IP Restriction Tests +// ========================================================================= - public function test_update_ip_restrictions_sets_values(): void - { - $key = $this->service->create($this->workspace, 'Test Key'); +describe('IP restrictions', function () { + it('updateIpRestrictions sets values', function () { + $workspace = createWorkspace(); + $service = app(AgentApiKeyService::class); + $key = $service->create($workspace, 'Test Key'); - $this->service->updateIpRestrictions($key, true, ['192.168.1.1', '10.0.0.0/8']); + $service->updateIpRestrictions($key, true, ['192.168.1.1', '10.0.0.0/8']); $fresh = $key->fresh(); - $this->assertTrue($fresh->ip_restriction_enabled); - $this->assertEquals(['192.168.1.1', '10.0.0.0/8'], $fresh->ip_whitelist); - } + expect($fresh->ip_restriction_enabled)->toBeTrue() + ->and($fresh->ip_whitelist)->toBe(['192.168.1.1', '10.0.0.0/8']); + }); - public function test_enable_ip_restrictions_enables_with_whitelist(): void - { - $key = $this->service->create($this->workspace, 'Test Key'); + it('enableIpRestrictions enables with whitelist', function () { + $workspace = createWorkspace(); + $service = app(AgentApiKeyService::class); + $key = $service->create($workspace, 'Test Key'); - $this->service->enableIpRestrictions($key, ['192.168.1.1']); + $service->enableIpRestrictions($key, ['192.168.1.1']); $fresh = $key->fresh(); - $this->assertTrue($fresh->ip_restriction_enabled); - $this->assertEquals(['192.168.1.1'], $fresh->ip_whitelist); - } + expect($fresh->ip_restriction_enabled)->toBeTrue() + ->and($fresh->ip_whitelist)->toBe(['192.168.1.1']); + }); - public function test_disable_ip_restrictions_disables(): void - { - $key = $this->service->create($this->workspace, 'Test Key'); - $this->service->enableIpRestrictions($key, ['192.168.1.1']); + it('disableIpRestrictions disables restrictions', function () { + $workspace = createWorkspace(); + $service = app(AgentApiKeyService::class); + $key = $service->create($workspace, 'Test Key'); + $service->enableIpRestrictions($key, ['192.168.1.1']); - $this->service->disableIpRestrictions($key); + $service->disableIpRestrictions($key); - $this->assertFalse($key->fresh()->ip_restriction_enabled); - } + expect($key->fresh()->ip_restriction_enabled)->toBeFalse(); + }); - public function test_is_ip_allowed_returns_true_when_restrictions_disabled(): void - { - $key = $this->service->create($this->workspace, 'Test Key'); + it('isIpAllowed returns true when restrictions disabled', function () { + $workspace = createWorkspace(); + $service = app(AgentApiKeyService::class); + $key = $service->create($workspace, 'Test Key'); - $result = $this->service->isIpAllowed($key, '192.168.1.100'); + $result = $service->isIpAllowed($key, '192.168.1.100'); - $this->assertTrue($result); - } + expect($result)->toBeTrue(); + }); - public function test_is_ip_allowed_returns_true_when_ip_in_whitelist(): void - { - $key = $this->service->create($this->workspace, 'Test Key'); - $this->service->enableIpRestrictions($key, ['192.168.1.100']); + it('isIpAllowed returns true when IP in whitelist', function () { + $workspace = createWorkspace(); + $service = app(AgentApiKeyService::class); + $key = $service->create($workspace, 'Test Key'); + $service->enableIpRestrictions($key, ['192.168.1.100']); - $result = $this->service->isIpAllowed($key->fresh(), '192.168.1.100'); + $result = $service->isIpAllowed($key->fresh(), '192.168.1.100'); - $this->assertTrue($result); - } + expect($result)->toBeTrue(); + }); - public function test_is_ip_allowed_returns_false_when_ip_not_in_whitelist(): void - { - $key = $this->service->create($this->workspace, 'Test Key'); - $this->service->enableIpRestrictions($key, ['192.168.1.100']); + it('isIpAllowed returns false when IP not in whitelist', function () { + $workspace = createWorkspace(); + $service = app(AgentApiKeyService::class); + $key = $service->create($workspace, 'Test Key'); + $service->enableIpRestrictions($key, ['192.168.1.100']); - $result = $this->service->isIpAllowed($key->fresh(), '10.0.0.1'); + $result = $service->isIpAllowed($key->fresh(), '10.0.0.1'); - $this->assertFalse($result); - } + expect($result)->toBeFalse(); + }); - public function test_is_ip_allowed_supports_cidr_ranges(): void - { - $key = $this->service->create($this->workspace, 'Test Key'); - $this->service->enableIpRestrictions($key, ['192.168.1.0/24']); + it('isIpAllowed supports CIDR ranges', function () { + $workspace = createWorkspace(); + $service = app(AgentApiKeyService::class); + $key = $service->create($workspace, 'Test Key'); + $service->enableIpRestrictions($key, ['192.168.1.0/24']); $fresh = $key->fresh(); - $this->assertTrue($this->service->isIpAllowed($fresh, '192.168.1.50')); - $this->assertTrue($this->service->isIpAllowed($fresh, '192.168.1.254')); - $this->assertFalse($this->service->isIpAllowed($fresh, '192.168.2.1')); - } + expect($service->isIpAllowed($fresh, '192.168.1.50'))->toBeTrue() + ->and($service->isIpAllowed($fresh, '192.168.1.254'))->toBeTrue() + ->and($service->isIpAllowed($fresh, '192.168.2.1'))->toBeFalse(); + }); - public function test_parse_ip_whitelist_input_parses_valid_input(): void - { + it('parseIpWhitelistInput parses valid input', function () { + $service = app(AgentApiKeyService::class); $input = "192.168.1.1\n192.168.1.2\n10.0.0.0/8"; - $result = $this->service->parseIpWhitelistInput($input); + $result = $service->parseIpWhitelistInput($input); - $this->assertEmpty($result['errors']); - $this->assertCount(3, $result['entries']); - $this->assertContains('192.168.1.1', $result['entries']); - $this->assertContains('192.168.1.2', $result['entries']); - $this->assertContains('10.0.0.0/8', $result['entries']); - } + expect($result['errors'])->toBeEmpty() + ->and($result['entries'])->toHaveCount(3) + ->and($result['entries'])->toContain('192.168.1.1') + ->and($result['entries'])->toContain('192.168.1.2') + ->and($result['entries'])->toContain('10.0.0.0/8'); + }); - public function test_parse_ip_whitelist_input_returns_errors_for_invalid(): void - { + it('parseIpWhitelistInput returns errors for invalid entries', function () { + $service = app(AgentApiKeyService::class); $input = "192.168.1.1\ninvalid_ip\n10.0.0.0/8"; - $result = $this->service->parseIpWhitelistInput($input); + $result = $service->parseIpWhitelistInput($input); - $this->assertCount(1, $result['errors']); - $this->assertCount(2, $result['entries']); - } + expect($result['errors'])->toHaveCount(1) + ->and($result['entries'])->toHaveCount(2); + }); +}); - // ========================================================================= - // Workspace Query Tests - // ========================================================================= +// ========================================================================= +// Workspace Query Tests +// ========================================================================= - public function test_get_active_keys_for_workspace_returns_active_only(): void - { - $active = $this->service->create($this->workspace, 'Active Key'); - $revoked = $this->service->create($this->workspace, 'Revoked Key'); +describe('workspace queries', function () { + it('getActiveKeysForWorkspace returns active keys only', function () { + $workspace = createWorkspace(); + $service = app(AgentApiKeyService::class); + + $active = $service->create($workspace, 'Active Key'); + $revoked = $service->create($workspace, 'Revoked Key'); $revoked->revoke(); - $this->service->create( - $this->workspace, - 'Expired Key', - [], - 100, - Carbon::now()->subDay() - ); + $service->create($workspace, 'Expired Key', [], 100, Carbon::now()->subDay()); - $keys = $this->service->getActiveKeysForWorkspace($this->workspace); + $keys = $service->getActiveKeysForWorkspace($workspace); - $this->assertCount(1, $keys); - $this->assertEquals('Active Key', $keys->first()->name); - } + expect($keys)->toHaveCount(1) + ->and($keys->first()->name)->toBe('Active Key'); + }); - public function test_get_active_keys_for_workspace_filters_by_workspace(): void - { - $otherWorkspace = Workspace::factory()->create(); + it('getActiveKeysForWorkspace filters by workspace', function () { + $workspace = createWorkspace(); + $otherWorkspace = createWorkspace(); + $service = app(AgentApiKeyService::class); - $this->service->create($this->workspace, 'Our Key'); - $this->service->create($otherWorkspace, 'Their Key'); + $service->create($workspace, 'Our Key'); + $service->create($otherWorkspace, 'Their Key'); - $keys = $this->service->getActiveKeysForWorkspace($this->workspace); + $keys = $service->getActiveKeysForWorkspace($workspace); - $this->assertCount(1, $keys); - $this->assertEquals('Our Key', $keys->first()->name); - } + expect($keys)->toHaveCount(1) + ->and($keys->first()->name)->toBe('Our Key'); + }); - public function test_get_all_keys_for_workspace_returns_all(): void - { - $this->service->create($this->workspace, 'Active Key'); - $revoked = $this->service->create($this->workspace, 'Revoked Key'); + it('getAllKeysForWorkspace returns all keys', function () { + $workspace = createWorkspace(); + $service = app(AgentApiKeyService::class); + + $service->create($workspace, 'Active Key'); + $revoked = $service->create($workspace, 'Revoked Key'); $revoked->revoke(); - $this->service->create( - $this->workspace, - 'Expired Key', - [], - 100, - Carbon::now()->subDay() - ); + $service->create($workspace, 'Expired Key', [], 100, Carbon::now()->subDay()); - $keys = $this->service->getAllKeysForWorkspace($this->workspace); + $keys = $service->getAllKeysForWorkspace($workspace); - $this->assertCount(3, $keys); - } + expect($keys)->toHaveCount(3); + }); +}); - // ========================================================================= - // Validate With Permission Tests - // ========================================================================= +// ========================================================================= +// Validate With Permission Tests +// ========================================================================= - public function test_validate_with_permission_returns_key_when_valid(): void - { - $key = $this->service->create( - $this->workspace, +describe('validateWithPermission', function () { + it('returns key when valid with permission', function () { + $workspace = createWorkspace(); + $service = app(AgentApiKeyService::class); + $key = $service->create( + $workspace, 'Test Key', [AgentApiKey::PERM_PLANS_READ] ); $plainKey = $key->plainTextKey; - $result = $this->service->validateWithPermission($plainKey, AgentApiKey::PERM_PLANS_READ); + $result = $service->validateWithPermission($plainKey, AgentApiKey::PERM_PLANS_READ); - $this->assertNotNull($result); - $this->assertEquals($key->id, $result->id); - } + expect($result) + ->not->toBeNull() + ->and($result->id)->toBe($key->id); + }); - public function test_validate_with_permission_returns_null_for_invalid_key(): void - { - $result = $this->service->validateWithPermission( + it('returns null for invalid key', function () { + $service = app(AgentApiKeyService::class); + + $result = $service->validateWithPermission( 'ak_invalid_key', AgentApiKey::PERM_PLANS_READ ); - $this->assertNull($result); - } + expect($result)->toBeNull(); + }); - public function test_validate_with_permission_returns_null_without_permission(): void - { - $key = $this->service->create( - $this->workspace, + it('returns null without required permission', function () { + $workspace = createWorkspace(); + $service = app(AgentApiKeyService::class); + $key = $service->create( + $workspace, 'Test Key', [AgentApiKey::PERM_SESSIONS_READ] ); $plainKey = $key->plainTextKey; - $result = $this->service->validateWithPermission($plainKey, AgentApiKey::PERM_PLANS_READ); + $result = $service->validateWithPermission($plainKey, AgentApiKey::PERM_PLANS_READ); - $this->assertNull($result); - } + expect($result)->toBeNull(); + }); - public function test_validate_with_permission_returns_null_when_rate_limited(): void - { - $key = $this->service->create( - $this->workspace, + it('returns null when rate limited', function () { + $workspace = createWorkspace(); + $service = app(AgentApiKeyService::class); + $key = $service->create( + $workspace, 'Test Key', [AgentApiKey::PERM_PLANS_READ], 100 @@ -593,63 +601,66 @@ class AgentApiKeyServiceTest extends TestCase $plainKey = $key->plainTextKey; Cache::put("agent_api_key_rate:{$key->id}", 150, 60); - $result = $this->service->validateWithPermission($plainKey, AgentApiKey::PERM_PLANS_READ); + $result = $service->validateWithPermission($plainKey, AgentApiKey::PERM_PLANS_READ); - $this->assertNull($result); - } + expect($result)->toBeNull(); + }); +}); - // ========================================================================= - // Full Authentication Flow Tests - // ========================================================================= +// ========================================================================= +// Full Authentication Flow Tests +// ========================================================================= - public function test_authenticate_returns_success_for_valid_key(): void - { - $key = $this->service->create( - $this->workspace, +describe('authenticate', function () { + it('returns success for valid key with permission', function () { + $workspace = createWorkspace(); + $service = app(AgentApiKeyService::class); + $key = $service->create( + $workspace, 'Test Key', [AgentApiKey::PERM_PLANS_READ] ); $plainKey = $key->plainTextKey; - $result = $this->service->authenticate($plainKey, AgentApiKey::PERM_PLANS_READ); + $result = $service->authenticate($plainKey, AgentApiKey::PERM_PLANS_READ); - $this->assertTrue($result['success']); - $this->assertInstanceOf(AgentApiKey::class, $result['key']); - $this->assertEquals($this->workspace->id, $result['workspace_id']); - $this->assertArrayHasKey('rate_limit', $result); - } + expect($result['success'])->toBeTrue() + ->and($result['key'])->toBeInstanceOf(AgentApiKey::class) + ->and($result['workspace_id'])->toBe($workspace->id) + ->and($result)->toHaveKey('rate_limit'); + }); - public function test_authenticate_returns_error_for_invalid_key(): void - { - $result = $this->service->authenticate( - 'ak_invalid_key', - AgentApiKey::PERM_PLANS_READ - ); + it('returns error for invalid key', function () { + $service = app(AgentApiKeyService::class); - $this->assertFalse($result['success']); - $this->assertEquals('invalid_key', $result['error']); - } + $result = $service->authenticate('ak_invalid_key', AgentApiKey::PERM_PLANS_READ); - public function test_authenticate_returns_error_for_revoked_key(): void - { - $key = $this->service->create( - $this->workspace, + expect($result['success'])->toBeFalse() + ->and($result['error'])->toBe('invalid_key'); + }); + + it('returns error for revoked key', function () { + $workspace = createWorkspace(); + $service = app(AgentApiKeyService::class); + $key = $service->create( + $workspace, 'Test Key', [AgentApiKey::PERM_PLANS_READ] ); $plainKey = $key->plainTextKey; $key->revoke(); - $result = $this->service->authenticate($plainKey, AgentApiKey::PERM_PLANS_READ); + $result = $service->authenticate($plainKey, AgentApiKey::PERM_PLANS_READ); - $this->assertFalse($result['success']); - $this->assertEquals('key_revoked', $result['error']); - } + expect($result['success'])->toBeFalse() + ->and($result['error'])->toBe('key_revoked'); + }); - public function test_authenticate_returns_error_for_expired_key(): void - { - $key = $this->service->create( - $this->workspace, + it('returns error for expired key', function () { + $workspace = createWorkspace(); + $service = app(AgentApiKeyService::class); + $key = $service->create( + $workspace, 'Test Key', [AgentApiKey::PERM_PLANS_READ], 100, @@ -657,31 +668,33 @@ class AgentApiKeyServiceTest extends TestCase ); $plainKey = $key->plainTextKey; - $result = $this->service->authenticate($plainKey, AgentApiKey::PERM_PLANS_READ); + $result = $service->authenticate($plainKey, AgentApiKey::PERM_PLANS_READ); - $this->assertFalse($result['success']); - $this->assertEquals('key_expired', $result['error']); - } + expect($result['success'])->toBeFalse() + ->and($result['error'])->toBe('key_expired'); + }); - public function test_authenticate_returns_error_for_missing_permission(): void - { - $key = $this->service->create( - $this->workspace, + it('returns error for missing permission', function () { + $workspace = createWorkspace(); + $service = app(AgentApiKeyService::class); + $key = $service->create( + $workspace, 'Test Key', [AgentApiKey::PERM_SESSIONS_READ] ); $plainKey = $key->plainTextKey; - $result = $this->service->authenticate($plainKey, AgentApiKey::PERM_PLANS_READ); + $result = $service->authenticate($plainKey, AgentApiKey::PERM_PLANS_READ); - $this->assertFalse($result['success']); - $this->assertEquals('permission_denied', $result['error']); - } + expect($result['success'])->toBeFalse() + ->and($result['error'])->toBe('permission_denied'); + }); - public function test_authenticate_returns_error_when_rate_limited(): void - { - $key = $this->service->create( - $this->workspace, + it('returns error when rate limited', function () { + $workspace = createWorkspace(); + $service = app(AgentApiKeyService::class); + $key = $service->create( + $workspace, 'Test Key', [AgentApiKey::PERM_PLANS_READ], 100 @@ -689,86 +702,156 @@ class AgentApiKeyServiceTest extends TestCase $plainKey = $key->plainTextKey; Cache::put("agent_api_key_rate:{$key->id}", 150, 60); - $result = $this->service->authenticate($plainKey, AgentApiKey::PERM_PLANS_READ); + $result = $service->authenticate($plainKey, AgentApiKey::PERM_PLANS_READ); - $this->assertFalse($result['success']); - $this->assertEquals('rate_limited', $result['error']); - $this->assertArrayHasKey('rate_limit', $result); - } + expect($result['success'])->toBeFalse() + ->and($result['error'])->toBe('rate_limited') + ->and($result)->toHaveKey('rate_limit'); + }); - public function test_authenticate_checks_ip_restrictions(): void - { - $key = $this->service->create( - $this->workspace, + it('checks IP restrictions', function () { + $workspace = createWorkspace(); + $service = app(AgentApiKeyService::class); + $key = $service->create( + $workspace, 'Test Key', [AgentApiKey::PERM_PLANS_READ] ); $plainKey = $key->plainTextKey; - $this->service->enableIpRestrictions($key, ['192.168.1.100']); + $service->enableIpRestrictions($key, ['192.168.1.100']); - $result = $this->service->authenticate( - $plainKey, - AgentApiKey::PERM_PLANS_READ, - '10.0.0.1' - ); + $result = $service->authenticate($plainKey, AgentApiKey::PERM_PLANS_READ, '10.0.0.1'); - $this->assertFalse($result['success']); - $this->assertEquals('ip_not_allowed', $result['error']); - } + expect($result['success'])->toBeFalse() + ->and($result['error'])->toBe('ip_not_allowed'); + }); - public function test_authenticate_allows_whitelisted_ip(): void - { - $key = $this->service->create( - $this->workspace, + it('allows whitelisted IP', function () { + $workspace = createWorkspace(); + $service = app(AgentApiKeyService::class); + $key = $service->create( + $workspace, 'Test Key', [AgentApiKey::PERM_PLANS_READ] ); $plainKey = $key->plainTextKey; - $this->service->enableIpRestrictions($key, ['192.168.1.100']); + $service->enableIpRestrictions($key, ['192.168.1.100']); - $result = $this->service->authenticate( - $plainKey, - AgentApiKey::PERM_PLANS_READ, - '192.168.1.100' - ); + $result = $service->authenticate($plainKey, AgentApiKey::PERM_PLANS_READ, '192.168.1.100'); - $this->assertTrue($result['success']); - $this->assertEquals('192.168.1.100', $result['client_ip']); - } + expect($result['success'])->toBeTrue() + ->and($result['client_ip'])->toBe('192.168.1.100'); + }); - public function test_authenticate_records_usage_on_success(): void - { - $key = $this->service->create( - $this->workspace, + it('records usage on success', function () { + $workspace = createWorkspace(); + $service = app(AgentApiKeyService::class); + $key = $service->create( + $workspace, 'Test Key', [AgentApiKey::PERM_PLANS_READ] ); $plainKey = $key->plainTextKey; Cache::forget("agent_api_key_rate:{$key->id}"); - $this->service->authenticate( - $plainKey, - AgentApiKey::PERM_PLANS_READ, - '192.168.1.50' - ); + $service->authenticate($plainKey, AgentApiKey::PERM_PLANS_READ, '192.168.1.50'); $fresh = $key->fresh(); - $this->assertEquals(1, $fresh->call_count); - $this->assertNotNull($fresh->last_used_at); - $this->assertEquals('192.168.1.50', $fresh->last_used_ip); - } + expect($fresh->call_count)->toBe(1) + ->and($fresh->last_used_at)->not->toBeNull() + ->and($fresh->last_used_ip)->toBe('192.168.1.50'); + }); - public function test_authenticate_does_not_record_usage_on_failure(): void - { - $key = $this->service->create( - $this->workspace, + it('does not record usage on failure', function () { + $workspace = createWorkspace(); + $service = app(AgentApiKeyService::class); + $key = $service->create($workspace, 'Test Key', []); + $plainKey = $key->plainTextKey; + + $service->authenticate($plainKey, AgentApiKey::PERM_PLANS_READ); + + expect($key->fresh()->call_count)->toBe(0); + }); +}); + +// ========================================================================= +// Edge Cases and Security Tests +// ========================================================================= + +describe('edge cases', function () { + it('handles empty permissions array', function () { + $workspace = createWorkspace(); + $service = app(AgentApiKeyService::class); + + $key = $service->create($workspace, 'Test Key', []); + + expect($key->permissions)->toBe([]) + ->and($service->checkPermission($key, AgentApiKey::PERM_PLANS_READ))->toBeFalse(); + }); + + it('handles multiple workspaces correctly', function () { + $workspace1 = createWorkspace(); + $workspace2 = createWorkspace(); + $service = app(AgentApiKeyService::class); + + $key1 = $service->create($workspace1, 'Workspace 1 Key'); + $key2 = $service->create($workspace2, 'Workspace 2 Key'); + + expect($key1->workspace_id)->toBe($workspace1->id) + ->and($key2->workspace_id)->toBe($workspace2->id); + + $workspace1Keys = $service->getAllKeysForWorkspace($workspace1); + $workspace2Keys = $service->getAllKeysForWorkspace($workspace2); + + expect($workspace1Keys)->toHaveCount(1) + ->and($workspace2Keys)->toHaveCount(1); + }); + + it('handles concurrent rate limit updates atomically', function () { + $workspace = createWorkspace(); + $service = app(AgentApiKeyService::class); + $key = $service->create($workspace, 'Test Key'); + Cache::forget("agent_api_key_rate:{$key->id}"); + + // Simulate rapid concurrent requests + for ($i = 0; $i < 10; $i++) { + $service->recordUsage($key); + } + + expect(Cache::get("agent_api_key_rate:{$key->id}"))->toBe(10); + }); + + it('handles null client IP gracefully', function () { + $workspace = createWorkspace(); + $service = app(AgentApiKeyService::class); + $key = $service->create( + $workspace, 'Test Key', - [] + [AgentApiKey::PERM_PLANS_READ] ); $plainKey = $key->plainTextKey; - $this->service->authenticate($plainKey, AgentApiKey::PERM_PLANS_READ); + $result = $service->authenticate($plainKey, AgentApiKey::PERM_PLANS_READ, null); - $this->assertEquals(0, $key->fresh()->call_count); - } -} + expect($result['success'])->toBeTrue() + ->and($result['client_ip'])->toBeNull(); + }); + + it('validates key before checking IP restrictions', function () { + $workspace = createWorkspace(); + $service = app(AgentApiKeyService::class); + $key = $service->create( + $workspace, + 'Test Key', + [AgentApiKey::PERM_PLANS_READ] + ); + $plainKey = $key->plainTextKey; + $service->enableIpRestrictions($key, ['192.168.1.100']); + $key->revoke(); + + // Should fail on revoked check before IP check + $result = $service->authenticate($plainKey, AgentApiKey::PERM_PLANS_READ, '10.0.0.1'); + + expect($result['error'])->toBe('key_revoked'); + }); +}); diff --git a/tests/Feature/IpRestrictionServiceTest.php b/tests/Feature/IpRestrictionServiceTest.php index d0b5b3d..d43e7e3 100644 --- a/tests/Feature/IpRestrictionServiceTest.php +++ b/tests/Feature/IpRestrictionServiceTest.php @@ -2,589 +2,640 @@ declare(strict_types=1); -namespace Core\Mod\Agentic\Tests\Feature; - -use Core\Mod\Agentic\Models\AgentApiKey; -use Core\Mod\Agentic\Services\IpRestrictionService; -use Core\Tenant\Models\Workspace; -use Illuminate\Foundation\Testing\RefreshDatabase; -use Tests\TestCase; - /** * Tests for the IpRestrictionService. * * Covers IPv4/IPv6 validation, CIDR matching, and edge cases. */ -class IpRestrictionServiceTest extends TestCase -{ - use RefreshDatabase; - - private Workspace $workspace; - - private IpRestrictionService $service; - - protected function setUp(): void - { - parent::setUp(); - $this->workspace = Workspace::factory()->create(); - $this->service = app(IpRestrictionService::class); - } - - // ========================================================================= - // IPv4 Basic Tests - // ========================================================================= - - public function test_validates_exact_ipv4_match(): void - { - $result = $this->service->isIpInWhitelist('192.168.1.100', ['192.168.1.100']); - - $this->assertTrue($result); - } - - public function test_rejects_non_matching_ipv4(): void - { - $result = $this->service->isIpInWhitelist('192.168.1.100', ['192.168.1.200']); - - $this->assertFalse($result); - } - - public function test_validates_ipv4_in_multiple_entries(): void - { - $whitelist = ['10.0.0.1', '192.168.1.100', '172.16.0.1']; - - $result = $this->service->isIpInWhitelist('192.168.1.100', $whitelist); - - $this->assertTrue($result); - } - - public function test_rejects_invalid_ipv4(): void - { - $result = $this->service->isIpInWhitelist('invalid', ['192.168.1.100']); - - $this->assertFalse($result); - } - - public function test_rejects_ipv4_out_of_range(): void - { - $result = $this->service->isIpInWhitelist('256.256.256.256', ['192.168.1.100']); - - $this->assertFalse($result); - } - - // ========================================================================= - // IPv4 CIDR Tests - // ========================================================================= - - public function test_validates_ipv4_in_cidr_24(): void - { - $this->assertTrue($this->service->isIpInWhitelist('192.168.1.0', ['192.168.1.0/24'])); - $this->assertTrue($this->service->isIpInWhitelist('192.168.1.1', ['192.168.1.0/24'])); - $this->assertTrue($this->service->isIpInWhitelist('192.168.1.128', ['192.168.1.0/24'])); - $this->assertTrue($this->service->isIpInWhitelist('192.168.1.255', ['192.168.1.0/24'])); - } - - public function test_rejects_ipv4_outside_cidr_24(): void - { - $this->assertFalse($this->service->isIpInWhitelist('192.168.2.0', ['192.168.1.0/24'])); - $this->assertFalse($this->service->isIpInWhitelist('192.168.0.255', ['192.168.1.0/24'])); - } - - public function test_validates_ipv4_in_cidr_16(): void - { - $this->assertTrue($this->service->isIpInWhitelist('192.168.0.1', ['192.168.0.0/16'])); - $this->assertTrue($this->service->isIpInWhitelist('192.168.255.255', ['192.168.0.0/16'])); - } - - public function test_rejects_ipv4_outside_cidr_16(): void - { - $this->assertFalse($this->service->isIpInWhitelist('192.169.0.1', ['192.168.0.0/16'])); - } - - public function test_validates_ipv4_in_cidr_8(): void - { - $this->assertTrue($this->service->isIpInWhitelist('10.0.0.1', ['10.0.0.0/8'])); - $this->assertTrue($this->service->isIpInWhitelist('10.255.255.255', ['10.0.0.0/8'])); - } - - public function test_rejects_ipv4_outside_cidr_8(): void - { - $this->assertFalse($this->service->isIpInWhitelist('11.0.0.1', ['10.0.0.0/8'])); - } - - public function test_validates_ipv4_in_cidr_32(): void - { - $this->assertTrue($this->service->isIpInWhitelist('192.168.1.100', ['192.168.1.100/32'])); - $this->assertFalse($this->service->isIpInWhitelist('192.168.1.101', ['192.168.1.100/32'])); - } - - public function test_validates_ipv4_in_cidr_0(): void - { - // /0 means all IPv4 addresses - $this->assertTrue($this->service->isIpInWhitelist('1.2.3.4', ['0.0.0.0/0'])); - $this->assertTrue($this->service->isIpInWhitelist('255.255.255.255', ['0.0.0.0/0'])); - } - - public function test_validates_ipv4_in_non_standard_cidr(): void - { - // /28 gives 16 addresses - $this->assertTrue($this->service->isIpInWhitelist('192.168.1.0', ['192.168.1.0/28'])); - $this->assertTrue($this->service->isIpInWhitelist('192.168.1.15', ['192.168.1.0/28'])); - $this->assertFalse($this->service->isIpInWhitelist('192.168.1.16', ['192.168.1.0/28'])); - } - - // ========================================================================= - // IPv6 Basic Tests - // ========================================================================= - - public function test_validates_exact_ipv6_match(): void - { - $result = $this->service->isIpInWhitelist('2001:db8::1', ['2001:db8::1']); - - $this->assertTrue($result); - } - - public function test_validates_localhost_ipv6(): void - { - $result = $this->service->isIpInWhitelist('::1', ['::1']); - - $this->assertTrue($result); - } - - public function test_rejects_non_matching_ipv6(): void - { - $result = $this->service->isIpInWhitelist('2001:db8::1', ['2001:db8::2']); - - $this->assertFalse($result); - } - - public function test_normalises_ipv6_for_comparison(): void - { - // These are the same address in different formats - $this->assertTrue($this->service->isIpInWhitelist( - '2001:0db8:0000:0000:0000:0000:0000:0001', - ['2001:db8::1'] - )); - } - - // ========================================================================= - // IPv6 CIDR Tests - // ========================================================================= - - public function test_validates_ipv6_in_cidr_64(): void - { - $this->assertTrue($this->service->isIpInWhitelist( - '2001:db8:abcd:0012::1', - ['2001:db8:abcd:0012::/64'] - )); - $this->assertTrue($this->service->isIpInWhitelist( - '2001:db8:abcd:0012:ffff:ffff:ffff:ffff', - ['2001:db8:abcd:0012::/64'] - )); - } - - public function test_rejects_ipv6_outside_cidr_64(): void - { - $this->assertFalse($this->service->isIpInWhitelist( - '2001:db8:abcd:0013::1', - ['2001:db8:abcd:0012::/64'] - )); - } - - public function test_validates_ipv6_in_cidr_32(): void - { - $this->assertTrue($this->service->isIpInWhitelist( - '2001:db8:0:0:0:0:0:1', - ['2001:db8::/32'] - )); - $this->assertTrue($this->service->isIpInWhitelist( - '2001:db8:ffff:ffff:ffff:ffff:ffff:ffff', - ['2001:db8::/32'] - )); - } - - public function test_rejects_ipv6_outside_cidr_32(): void - { - $this->assertFalse($this->service->isIpInWhitelist( - '2001:db9::1', - ['2001:db8::/32'] - )); - } - - public function test_validates_ipv6_in_cidr_128(): void - { - $this->assertTrue($this->service->isIpInWhitelist( - '2001:db8::1', - ['2001:db8::1/128'] - )); - $this->assertFalse($this->service->isIpInWhitelist( - '2001:db8::2', - ['2001:db8::1/128'] - )); - } - - // ========================================================================= - // IPv4/IPv6 Mixed Tests - // ========================================================================= - - public function test_ipv4_does_not_match_ipv6_cidr(): void - { - $this->assertFalse($this->service->isIpInWhitelist( - '192.168.1.1', - ['2001:db8::/32'] - )); - } - - public function test_ipv6_does_not_match_ipv4_cidr(): void - { - $this->assertFalse($this->service->isIpInWhitelist( - '2001:db8::1', - ['192.168.1.0/24'] - )); - } - - public function test_whitelist_can_contain_both_ipv4_and_ipv6(): void - { - $whitelist = ['192.168.1.0/24', '2001:db8::/32']; - - $this->assertTrue($this->service->isIpInWhitelist('192.168.1.100', $whitelist)); - $this->assertTrue($this->service->isIpInWhitelist('2001:db8::1', $whitelist)); - $this->assertFalse($this->service->isIpInWhitelist('10.0.0.1', $whitelist)); - } - - // ========================================================================= - // API Key Integration Tests - // ========================================================================= - - public function test_validate_ip_returns_true_when_restrictions_disabled(): void - { - $key = AgentApiKey::generate($this->workspace, 'Test Key'); - - $result = $this->service->validateIp($key, '192.168.1.100'); - - $this->assertTrue($result); - } - - public function test_validate_ip_returns_false_when_enabled_with_empty_whitelist(): void - { - $key = AgentApiKey::generate($this->workspace, 'Test Key'); - $key->enableIpRestriction(); - - $result = $this->service->validateIp($key->fresh(), '192.168.1.100'); - - $this->assertFalse($result); - } - - public function test_validate_ip_checks_whitelist(): void - { - $key = AgentApiKey::generate($this->workspace, 'Test Key'); - $key->enableIpRestriction(); - $key->updateIpWhitelist(['192.168.1.100', '10.0.0.0/8']); - - $fresh = $key->fresh(); - - $this->assertTrue($this->service->validateIp($fresh, '192.168.1.100')); - $this->assertTrue($this->service->validateIp($fresh, '10.0.0.50')); - $this->assertFalse($this->service->validateIp($fresh, '172.16.0.1')); - } - - // ========================================================================= - // Entry Validation Tests - // ========================================================================= - - public function test_validate_entry_accepts_valid_ipv4(): void - { - $result = $this->service->validateEntry('192.168.1.1'); - - $this->assertTrue($result['valid']); - $this->assertNull($result['error']); - } - - public function test_validate_entry_accepts_valid_ipv6(): void - { - $result = $this->service->validateEntry('2001:db8::1'); - - $this->assertTrue($result['valid']); - $this->assertNull($result['error']); - } - - public function test_validate_entry_accepts_valid_ipv4_cidr(): void - { - $result = $this->service->validateEntry('192.168.1.0/24'); - - $this->assertTrue($result['valid']); - $this->assertNull($result['error']); - } - - public function test_validate_entry_accepts_valid_ipv6_cidr(): void - { - $result = $this->service->validateEntry('2001:db8::/32'); - - $this->assertTrue($result['valid']); - $this->assertNull($result['error']); - } - - public function test_validate_entry_rejects_empty(): void - { - $result = $this->service->validateEntry(''); - - $this->assertFalse($result['valid']); - $this->assertEquals('Empty entry', $result['error']); - } - public function test_validate_entry_rejects_invalid_ip(): void - { - $result = $this->service->validateEntry('not-an-ip'); +use Core\Mod\Agentic\Models\AgentApiKey; +use Core\Mod\Agentic\Services\IpRestrictionService; +use Core\Tenant\Models\Workspace; - $this->assertFalse($result['valid']); - $this->assertEquals('Invalid IP address', $result['error']); - } +beforeEach(function (): void { + $this->workspace = Workspace::factory()->create(); + $this->service = app(IpRestrictionService::class); +}); + +// ============================================================================= +// IPv4 Basic Tests +// ============================================================================= + +test('validates exact IPv4 match', function (): void { + $result = $this->service->isIpInWhitelist('192.168.1.100', ['192.168.1.100']); + + expect($result)->toBeTrue(); +}); + +test('rejects non-matching IPv4', function (): void { + $result = $this->service->isIpInWhitelist('192.168.1.100', ['192.168.1.200']); + + expect($result)->toBeFalse(); +}); + +test('validates IPv4 in multiple entries', function (): void { + $whitelist = ['10.0.0.1', '192.168.1.100', '172.16.0.1']; + + $result = $this->service->isIpInWhitelist('192.168.1.100', $whitelist); + + expect($result)->toBeTrue(); +}); + +test('rejects invalid IPv4', function (): void { + $result = $this->service->isIpInWhitelist('invalid', ['192.168.1.100']); + + expect($result)->toBeFalse(); +}); + +test('rejects IPv4 out of range', function (): void { + $result = $this->service->isIpInWhitelist('256.256.256.256', ['192.168.1.100']); - public function test_validate_entry_rejects_invalid_cidr(): void - { - $result = $this->service->validateEntry('192.168.1.0/'); + expect($result)->toBeFalse(); +}); + +// ============================================================================= +// IPv4 CIDR Tests +// ============================================================================= + +test('validates IPv4 in CIDR /24', function (): void { + expect($this->service->isIpInWhitelist('192.168.1.0', ['192.168.1.0/24']))->toBeTrue(); + expect($this->service->isIpInWhitelist('192.168.1.1', ['192.168.1.0/24']))->toBeTrue(); + expect($this->service->isIpInWhitelist('192.168.1.128', ['192.168.1.0/24']))->toBeTrue(); + expect($this->service->isIpInWhitelist('192.168.1.255', ['192.168.1.0/24']))->toBeTrue(); +}); + +test('rejects IPv4 outside CIDR /24', function (): void { + expect($this->service->isIpInWhitelist('192.168.2.0', ['192.168.1.0/24']))->toBeFalse(); + expect($this->service->isIpInWhitelist('192.168.0.255', ['192.168.1.0/24']))->toBeFalse(); +}); + +test('validates IPv4 in CIDR /16', function (): void { + expect($this->service->isIpInWhitelist('192.168.0.1', ['192.168.0.0/16']))->toBeTrue(); + expect($this->service->isIpInWhitelist('192.168.255.255', ['192.168.0.0/16']))->toBeTrue(); +}); + +test('rejects IPv4 outside CIDR /16', function (): void { + expect($this->service->isIpInWhitelist('192.169.0.1', ['192.168.0.0/16']))->toBeFalse(); +}); + +test('validates IPv4 in CIDR /8', function (): void { + expect($this->service->isIpInWhitelist('10.0.0.1', ['10.0.0.0/8']))->toBeTrue(); + expect($this->service->isIpInWhitelist('10.255.255.255', ['10.0.0.0/8']))->toBeTrue(); +}); + +test('rejects IPv4 outside CIDR /8', function (): void { + expect($this->service->isIpInWhitelist('11.0.0.1', ['10.0.0.0/8']))->toBeFalse(); +}); + +test('validates IPv4 in CIDR /32', function (): void { + expect($this->service->isIpInWhitelist('192.168.1.100', ['192.168.1.100/32']))->toBeTrue(); + expect($this->service->isIpInWhitelist('192.168.1.101', ['192.168.1.100/32']))->toBeFalse(); +}); + +test('validates IPv4 in CIDR /0', function (): void { + // /0 means all IPv4 addresses + expect($this->service->isIpInWhitelist('1.2.3.4', ['0.0.0.0/0']))->toBeTrue(); + expect($this->service->isIpInWhitelist('255.255.255.255', ['0.0.0.0/0']))->toBeTrue(); +}); + +test('validates IPv4 in non-standard CIDR', function (): void { + // /28 gives 16 addresses + expect($this->service->isIpInWhitelist('192.168.1.0', ['192.168.1.0/28']))->toBeTrue(); + expect($this->service->isIpInWhitelist('192.168.1.15', ['192.168.1.0/28']))->toBeTrue(); + expect($this->service->isIpInWhitelist('192.168.1.16', ['192.168.1.0/28']))->toBeFalse(); +}); + +test('validates IPv4 in CIDR /25', function (): void { + // /25 gives 128 addresses (0-127) + expect($this->service->isIpInWhitelist('192.168.1.0', ['192.168.1.0/25']))->toBeTrue(); + expect($this->service->isIpInWhitelist('192.168.1.127', ['192.168.1.0/25']))->toBeTrue(); + expect($this->service->isIpInWhitelist('192.168.1.128', ['192.168.1.0/25']))->toBeFalse(); +}); + +test('validates IPv4 in CIDR /30', function (): void { + // /30 gives 4 addresses + expect($this->service->isIpInWhitelist('192.168.1.0', ['192.168.1.0/30']))->toBeTrue(); + expect($this->service->isIpInWhitelist('192.168.1.3', ['192.168.1.0/30']))->toBeTrue(); + expect($this->service->isIpInWhitelist('192.168.1.4', ['192.168.1.0/30']))->toBeFalse(); +}); + +test('validates IPv4 in CIDR /31', function (): void { + // /31 gives 2 addresses + expect($this->service->isIpInWhitelist('192.168.1.0', ['192.168.1.0/31']))->toBeTrue(); + expect($this->service->isIpInWhitelist('192.168.1.1', ['192.168.1.0/31']))->toBeTrue(); + expect($this->service->isIpInWhitelist('192.168.1.2', ['192.168.1.0/31']))->toBeFalse(); +}); + +// ============================================================================= +// IPv6 Basic Tests +// ============================================================================= + +test('validates exact IPv6 match', function (): void { + $result = $this->service->isIpInWhitelist('2001:db8::1', ['2001:db8::1']); + + expect($result)->toBeTrue(); +}); + +test('validates localhost IPv6', function (): void { + $result = $this->service->isIpInWhitelist('::1', ['::1']); + + expect($result)->toBeTrue(); +}); + +test('rejects non-matching IPv6', function (): void { + $result = $this->service->isIpInWhitelist('2001:db8::1', ['2001:db8::2']); + + expect($result)->toBeFalse(); +}); + +test('normalises IPv6 for comparison', function (): void { + // These are the same address in different formats + $result = $this->service->isIpInWhitelist( + '2001:0db8:0000:0000:0000:0000:0000:0001', + ['2001:db8::1'] + ); + + expect($result)->toBeTrue(); +}); + +test('validates full IPv6 address', function (): void { + $result = $this->service->isIpInWhitelist( + '2001:0db8:85a3:0000:0000:8a2e:0370:7334', + ['2001:db8:85a3::8a2e:370:7334'] + ); + + expect($result)->toBeTrue(); +}); + +// ============================================================================= +// IPv6 CIDR Tests +// ============================================================================= + +test('validates IPv6 in CIDR /64', function (): void { + expect($this->service->isIpInWhitelist( + '2001:db8:abcd:0012::1', + ['2001:db8:abcd:0012::/64'] + ))->toBeTrue(); + expect($this->service->isIpInWhitelist( + '2001:db8:abcd:0012:ffff:ffff:ffff:ffff', + ['2001:db8:abcd:0012::/64'] + ))->toBeTrue(); +}); + +test('rejects IPv6 outside CIDR /64', function (): void { + expect($this->service->isIpInWhitelist( + '2001:db8:abcd:0013::1', + ['2001:db8:abcd:0012::/64'] + ))->toBeFalse(); +}); + +test('validates IPv6 in CIDR /32', function (): void { + expect($this->service->isIpInWhitelist( + '2001:db8:0:0:0:0:0:1', + ['2001:db8::/32'] + ))->toBeTrue(); + expect($this->service->isIpInWhitelist( + '2001:db8:ffff:ffff:ffff:ffff:ffff:ffff', + ['2001:db8::/32'] + ))->toBeTrue(); +}); + +test('rejects IPv6 outside CIDR /32', function (): void { + expect($this->service->isIpInWhitelist( + '2001:db9::1', + ['2001:db8::/32'] + ))->toBeFalse(); +}); + +test('validates IPv6 in CIDR /128', function (): void { + expect($this->service->isIpInWhitelist( + '2001:db8::1', + ['2001:db8::1/128'] + ))->toBeTrue(); + expect($this->service->isIpInWhitelist( + '2001:db8::2', + ['2001:db8::1/128'] + ))->toBeFalse(); +}); + +test('validates IPv6 in CIDR /48', function (): void { + expect($this->service->isIpInWhitelist( + '2001:db8:abcd::1', + ['2001:db8:abcd::/48'] + ))->toBeTrue(); + expect($this->service->isIpInWhitelist( + '2001:db8:abcd:ffff:ffff:ffff:ffff:ffff', + ['2001:db8:abcd::/48'] + ))->toBeTrue(); + expect($this->service->isIpInWhitelist( + '2001:db8:abce::1', + ['2001:db8:abcd::/48'] + ))->toBeFalse(); +}); + +test('validates IPv6 in CIDR /0', function (): void { + // /0 means all IPv6 addresses + expect($this->service->isIpInWhitelist('::1', ['::/0']))->toBeTrue(); + expect($this->service->isIpInWhitelist('2001:db8::1', ['::/0']))->toBeTrue(); + expect($this->service->isIpInWhitelist('fe80::1', ['::/0']))->toBeTrue(); +}); + +test('validates IPv6 in CIDR /56', function (): void { + // /56 is common allocation size + expect($this->service->isIpInWhitelist( + '2001:db8:ab00::1', + ['2001:db8:ab00::/56'] + ))->toBeTrue(); + expect($this->service->isIpInWhitelist( + '2001:db8:ab00:ff::1', + ['2001:db8:ab00::/56'] + ))->toBeTrue(); + expect($this->service->isIpInWhitelist( + '2001:db8:ab01::1', + ['2001:db8:ab00::/56'] + ))->toBeFalse(); +}); + +// ============================================================================= +// IPv4/IPv6 Mixed Tests +// ============================================================================= + +test('IPv4 does not match IPv6 CIDR', function (): void { + expect($this->service->isIpInWhitelist( + '192.168.1.1', + ['2001:db8::/32'] + ))->toBeFalse(); +}); + +test('IPv6 does not match IPv4 CIDR', function (): void { + expect($this->service->isIpInWhitelist( + '2001:db8::1', + ['192.168.1.0/24'] + ))->toBeFalse(); +}); + +test('whitelist can contain both IPv4 and IPv6', function (): void { + $whitelist = ['192.168.1.0/24', '2001:db8::/32']; + + expect($this->service->isIpInWhitelist('192.168.1.100', $whitelist))->toBeTrue(); + expect($this->service->isIpInWhitelist('2001:db8::1', $whitelist))->toBeTrue(); + expect($this->service->isIpInWhitelist('10.0.0.1', $whitelist))->toBeFalse(); +}); + +// ============================================================================= +// API Key Integration Tests +// ============================================================================= + +test('validateIp returns true when restrictions disabled', function (): void { + $key = AgentApiKey::generate($this->workspace, 'Test Key'); + + $result = $this->service->validateIp($key, '192.168.1.100'); + + expect($result)->toBeTrue(); +}); + +test('validateIp returns false when enabled with empty whitelist', function (): void { + $key = AgentApiKey::generate($this->workspace, 'Test Key'); + $key->enableIpRestriction(); + + $result = $this->service->validateIp($key->fresh(), '192.168.1.100'); + + expect($result)->toBeFalse(); +}); + +test('validateIp checks whitelist', function (): void { + $key = AgentApiKey::generate($this->workspace, 'Test Key'); + $key->enableIpRestriction(); + $key->updateIpWhitelist(['192.168.1.100', '10.0.0.0/8']); + + $fresh = $key->fresh(); + + expect($this->service->validateIp($fresh, '192.168.1.100'))->toBeTrue(); + expect($this->service->validateIp($fresh, '10.0.0.50'))->toBeTrue(); + expect($this->service->validateIp($fresh, '172.16.0.1'))->toBeFalse(); +}); + +// ============================================================================= +// Entry Validation Tests +// ============================================================================= + +test('validateEntry accepts valid IPv4', function (): void { + $result = $this->service->validateEntry('192.168.1.1'); + + expect($result['valid'])->toBeTrue(); + expect($result['error'])->toBeNull(); +}); + +test('validateEntry accepts valid IPv6', function (): void { + $result = $this->service->validateEntry('2001:db8::1'); + + expect($result['valid'])->toBeTrue(); + expect($result['error'])->toBeNull(); +}); - $this->assertFalse($result['valid']); - } +test('validateEntry accepts valid IPv4 CIDR', function (): void { + $result = $this->service->validateEntry('192.168.1.0/24'); - // ========================================================================= - // CIDR Validation Tests - // ========================================================================= + expect($result['valid'])->toBeTrue(); + expect($result['error'])->toBeNull(); +}); - public function test_validate_cidr_accepts_valid_ipv4_prefixes(): void - { - $this->assertTrue($this->service->validateCidr('192.168.1.0/0')['valid']); - $this->assertTrue($this->service->validateCidr('192.168.1.0/16')['valid']); - $this->assertTrue($this->service->validateCidr('192.168.1.0/32')['valid']); - } +test('validateEntry accepts valid IPv6 CIDR', function (): void { + $result = $this->service->validateEntry('2001:db8::/32'); - public function test_validate_cidr_rejects_invalid_ipv4_prefixes(): void - { - $result = $this->service->validateCidr('192.168.1.0/33'); + expect($result['valid'])->toBeTrue(); + expect($result['error'])->toBeNull(); +}); - $this->assertFalse($result['valid']); - $this->assertStringContainsString('IPv4 prefix must be', $result['error']); - } +test('validateEntry rejects empty', function (): void { + $result = $this->service->validateEntry(''); - public function test_validate_cidr_accepts_valid_ipv6_prefixes(): void - { - $this->assertTrue($this->service->validateCidr('2001:db8::/0')['valid']); - $this->assertTrue($this->service->validateCidr('2001:db8::/64')['valid']); - $this->assertTrue($this->service->validateCidr('2001:db8::/128')['valid']); - } + expect($result['valid'])->toBeFalse(); + expect($result['error'])->toBe('Empty entry'); +}); - public function test_validate_cidr_rejects_invalid_ipv6_prefixes(): void - { - $result = $this->service->validateCidr('2001:db8::/129'); +test('validateEntry rejects invalid IP', function (): void { + $result = $this->service->validateEntry('not-an-ip'); - $this->assertFalse($result['valid']); - $this->assertStringContainsString('IPv6 prefix must be', $result['error']); - } + expect($result['valid'])->toBeFalse(); + expect($result['error'])->toBe('Invalid IP address'); +}); - public function test_validate_cidr_rejects_negative_prefix(): void - { - $result = $this->service->validateCidr('192.168.1.0/-1'); +test('validateEntry rejects invalid CIDR', function (): void { + $result = $this->service->validateEntry('192.168.1.0/'); - $this->assertFalse($result['valid']); - } + expect($result['valid'])->toBeFalse(); +}); - public function test_validate_cidr_rejects_non_numeric_prefix(): void - { - $result = $this->service->validateCidr('192.168.1.0/abc'); +// ============================================================================= +// CIDR Validation Tests +// ============================================================================= - $this->assertFalse($result['valid']); - $this->assertEquals('Invalid prefix length', $result['error']); - } +test('validateCidr accepts valid IPv4 prefixes', function (): void { + expect($this->service->validateCidr('192.168.1.0/0')['valid'])->toBeTrue(); + expect($this->service->validateCidr('192.168.1.0/16')['valid'])->toBeTrue(); + expect($this->service->validateCidr('192.168.1.0/32')['valid'])->toBeTrue(); +}); - public function test_validate_cidr_rejects_invalid_ip_in_cidr(): void - { - $result = $this->service->validateCidr('invalid/24'); +test('validateCidr rejects invalid IPv4 prefixes', function (): void { + $result = $this->service->validateCidr('192.168.1.0/33'); - $this->assertFalse($result['valid']); - $this->assertEquals('Invalid IP address in CIDR', $result['error']); - } + expect($result['valid'])->toBeFalse(); + expect($result['error'])->toContain('IPv4 prefix must be'); +}); - // ========================================================================= - // Parse Whitelist Input Tests - // ========================================================================= +test('validateCidr accepts valid IPv6 prefixes', function (): void { + expect($this->service->validateCidr('2001:db8::/0')['valid'])->toBeTrue(); + expect($this->service->validateCidr('2001:db8::/64')['valid'])->toBeTrue(); + expect($this->service->validateCidr('2001:db8::/128')['valid'])->toBeTrue(); +}); - public function test_parse_whitelist_input_handles_newlines(): void - { - $input = "192.168.1.1\n192.168.1.2\n192.168.1.3"; +test('validateCidr rejects invalid IPv6 prefixes', function (): void { + $result = $this->service->validateCidr('2001:db8::/129'); - $result = $this->service->parseWhitelistInput($input); + expect($result['valid'])->toBeFalse(); + expect($result['error'])->toContain('IPv6 prefix must be'); +}); - $this->assertCount(3, $result['entries']); - $this->assertEmpty($result['errors']); - } +test('validateCidr rejects negative prefix', function (): void { + $result = $this->service->validateCidr('192.168.1.0/-1'); - public function test_parse_whitelist_input_handles_commas(): void - { - $input = '192.168.1.1,192.168.1.2,192.168.1.3'; + expect($result['valid'])->toBeFalse(); +}); - $result = $this->service->parseWhitelistInput($input); +test('validateCidr rejects non-numeric prefix', function (): void { + $result = $this->service->validateCidr('192.168.1.0/abc'); - $this->assertCount(3, $result['entries']); - } + expect($result['valid'])->toBeFalse(); + expect($result['error'])->toBe('Invalid prefix length'); +}); - public function test_parse_whitelist_input_handles_carriage_returns(): void - { - $input = "192.168.1.1\r\n192.168.1.2\r\n192.168.1.3"; +test('validateCidr rejects invalid IP in CIDR', function (): void { + $result = $this->service->validateCidr('invalid/24'); - $result = $this->service->parseWhitelistInput($input); + expect($result['valid'])->toBeFalse(); + expect($result['error'])->toBe('Invalid IP address in CIDR'); +}); - $this->assertCount(3, $result['entries']); - } +// ============================================================================= +// Parse Whitelist Input Tests +// ============================================================================= - public function test_parse_whitelist_input_trims_whitespace(): void - { - $input = " 192.168.1.1 \n 192.168.1.2 "; +test('parseWhitelistInput handles newlines', function (): void { + $input = "192.168.1.1\n192.168.1.2\n192.168.1.3"; - $result = $this->service->parseWhitelistInput($input); + $result = $this->service->parseWhitelistInput($input); - $this->assertContains('192.168.1.1', $result['entries']); - $this->assertContains('192.168.1.2', $result['entries']); - } + expect($result['entries'])->toHaveCount(3); + expect($result['errors'])->toBeEmpty(); +}); - public function test_parse_whitelist_input_skips_empty_lines(): void - { - $input = "192.168.1.1\n\n\n192.168.1.2"; +test('parseWhitelistInput handles commas', function (): void { + $input = '192.168.1.1,192.168.1.2,192.168.1.3'; - $result = $this->service->parseWhitelistInput($input); + $result = $this->service->parseWhitelistInput($input); - $this->assertCount(2, $result['entries']); - } + expect($result['entries'])->toHaveCount(3); +}); - public function test_parse_whitelist_input_skips_comments(): void - { - $input = "# This is a comment\n192.168.1.1\n# Another comment\n192.168.1.2"; +test('parseWhitelistInput handles carriage returns', function (): void { + $input = "192.168.1.1\r\n192.168.1.2\r\n192.168.1.3"; - $result = $this->service->parseWhitelistInput($input); + $result = $this->service->parseWhitelistInput($input); - $this->assertCount(2, $result['entries']); - $this->assertNotContains('# This is a comment', $result['entries']); - } + expect($result['entries'])->toHaveCount(3); +}); - public function test_parse_whitelist_input_collects_errors(): void - { - $input = "192.168.1.1\ninvalid\n192.168.1.2\nalso-invalid"; +test('parseWhitelistInput trims whitespace', function (): void { + $input = " 192.168.1.1 \n 192.168.1.2 "; - $result = $this->service->parseWhitelistInput($input); + $result = $this->service->parseWhitelistInput($input); - $this->assertCount(2, $result['entries']); - $this->assertCount(2, $result['errors']); - } + expect($result['entries'])->toContain('192.168.1.1'); + expect($result['entries'])->toContain('192.168.1.2'); +}); - // ========================================================================= - // Format Whitelist Tests - // ========================================================================= +test('parseWhitelistInput skips empty lines', function (): void { + $input = "192.168.1.1\n\n\n192.168.1.2"; - public function test_format_whitelist_for_display_joins_with_newlines(): void - { - $whitelist = ['192.168.1.1', '10.0.0.0/8', '2001:db8::/32']; + $result = $this->service->parseWhitelistInput($input); - $result = $this->service->formatWhitelistForDisplay($whitelist); + expect($result['entries'])->toHaveCount(2); +}); - $this->assertEquals("192.168.1.1\n10.0.0.0/8\n2001:db8::/32", $result); - } +test('parseWhitelistInput skips comments', function (): void { + $input = "# This is a comment\n192.168.1.1\n# Another comment\n192.168.1.2"; - public function test_format_whitelist_for_display_handles_empty(): void - { - $result = $this->service->formatWhitelistForDisplay([]); + $result = $this->service->parseWhitelistInput($input); - $this->assertEquals('', $result); - } + expect($result['entries'])->toHaveCount(2); + expect($result['entries'])->not->toContain('# This is a comment'); +}); - // ========================================================================= - // Describe CIDR Tests - // ========================================================================= +test('parseWhitelistInput collects errors', function (): void { + $input = "192.168.1.1\ninvalid\n192.168.1.2\nalso-invalid"; - public function test_describe_cidr_for_ipv4(): void - { - $this->assertStringContainsString('256 addresses', $this->service->describeCidr('192.168.1.0/24')); - $this->assertStringContainsString('1 addresses', $this->service->describeCidr('192.168.1.0/32')); - } + $result = $this->service->parseWhitelistInput($input); - public function test_describe_cidr_for_ipv6(): void - { - $result = $this->service->describeCidr('2001:db8::/32'); + expect($result['entries'])->toHaveCount(2); + expect($result['errors'])->toHaveCount(2); +}); - $this->assertStringContainsString('2001:db8::/32', $result); - $this->assertStringContainsString('addresses', $result); - } +test('parseWhitelistInput handles mixed content', function (): void { + $input = "# Office IPs\n192.168.1.0/24\n# Cloud provider\n10.0.0.0/8\n# Invalid\ninvalid-ip"; - public function test_describe_cidr_returns_original_for_invalid(): void - { - $result = $this->service->describeCidr('invalid'); + $result = $this->service->parseWhitelistInput($input); - $this->assertEquals('invalid', $result); - } + expect($result['entries'])->toHaveCount(2); + expect($result['entries'])->toContain('192.168.1.0/24'); + expect($result['entries'])->toContain('10.0.0.0/8'); + expect($result['errors'])->toHaveCount(1); +}); - // ========================================================================= - // Normalise IP Tests - // ========================================================================= +// ============================================================================= +// Format Whitelist Tests +// ============================================================================= - public function test_normalise_ip_returns_same_for_ipv4(): void - { - $result = $this->service->normaliseIp('192.168.1.1'); +test('formatWhitelistForDisplay joins with newlines', function (): void { + $whitelist = ['192.168.1.1', '10.0.0.0/8', '2001:db8::/32']; - $this->assertEquals('192.168.1.1', $result); - } + $result = $this->service->formatWhitelistForDisplay($whitelist); - public function test_normalise_ip_compresses_ipv6(): void - { - $result = $this->service->normaliseIp('2001:0db8:0000:0000:0000:0000:0000:0001'); + expect($result)->toBe("192.168.1.1\n10.0.0.0/8\n2001:db8::/32"); +}); - $this->assertEquals('2001:db8::1', $result); - } +test('formatWhitelistForDisplay handles empty', function (): void { + $result = $this->service->formatWhitelistForDisplay([]); - public function test_normalise_ip_returns_original_for_invalid(): void - { - $result = $this->service->normaliseIp('invalid'); + expect($result)->toBe(''); +}); - $this->assertEquals('invalid', $result); - } +// ============================================================================= +// Describe CIDR Tests +// ============================================================================= - // ========================================================================= - // Edge Cases - // ========================================================================= +test('describeCidr for IPv4', function (): void { + expect($this->service->describeCidr('192.168.1.0/24'))->toContain('256 addresses'); + expect($this->service->describeCidr('192.168.1.0/32'))->toContain('1 addresses'); + expect($this->service->describeCidr('192.168.1.0/0'))->toContain('4294967296 addresses'); +}); - public function test_handles_trimmed_whitelist_entries(): void - { - $result = $this->service->isIpInWhitelist('192.168.1.1', [' 192.168.1.1 ']); +test('describeCidr for IPv6', function (): void { + $result = $this->service->describeCidr('2001:db8::/32'); - $this->assertTrue($result); - } + expect($result)->toContain('2001:db8::/32'); + expect($result)->toContain('addresses'); +}); - public function test_skips_empty_whitelist_entries(): void - { - $result = $this->service->isIpInWhitelist('192.168.1.1', ['', '192.168.1.1', '']); +test('describeCidr returns original for invalid', function (): void { + $result = $this->service->describeCidr('invalid'); - $this->assertTrue($result); - } + expect($result)->toBe('invalid'); +}); - public function test_returns_false_for_empty_whitelist(): void - { - $result = $this->service->isIpInWhitelist('192.168.1.1', []); +// ============================================================================= +// Normalise IP Tests +// ============================================================================= - $this->assertFalse($result); - } +test('normaliseIp returns same for IPv4', function (): void { + $result = $this->service->normaliseIp('192.168.1.1'); - public function test_handles_loopback_addresses(): void - { - $this->assertTrue($this->service->isIpInWhitelist('127.0.0.1', ['127.0.0.0/8'])); - $this->assertTrue($this->service->isIpInWhitelist('::1', ['::1'])); - } + expect($result)->toBe('192.168.1.1'); +}); - public function test_handles_private_ranges(): void - { - // RFC 1918 private ranges - $this->assertTrue($this->service->isIpInWhitelist('10.0.0.1', ['10.0.0.0/8'])); - $this->assertTrue($this->service->isIpInWhitelist('172.16.0.1', ['172.16.0.0/12'])); - $this->assertTrue($this->service->isIpInWhitelist('192.168.0.1', ['192.168.0.0/16'])); - } +test('normaliseIp compresses IPv6', function (): void { + $result = $this->service->normaliseIp('2001:0db8:0000:0000:0000:0000:0000:0001'); - public function test_handles_link_local_ipv6(): void - { - $this->assertTrue($this->service->isIpInWhitelist('fe80::1', ['fe80::/10'])); - } -} + expect($result)->toBe('2001:db8::1'); +}); + +test('normaliseIp returns original for invalid', function (): void { + $result = $this->service->normaliseIp('invalid'); + + expect($result)->toBe('invalid'); +}); + +test('normaliseIp handles trimming', function (): void { + $result = $this->service->normaliseIp(' 192.168.1.1 '); + + expect($result)->toBe('192.168.1.1'); +}); + +// ============================================================================= +// Edge Cases +// ============================================================================= + +test('handles trimmed whitelist entries', function (): void { + $result = $this->service->isIpInWhitelist('192.168.1.1', [' 192.168.1.1 ']); + + expect($result)->toBeTrue(); +}); + +test('skips empty whitelist entries', function (): void { + $result = $this->service->isIpInWhitelist('192.168.1.1', ['', '192.168.1.1', '']); + + expect($result)->toBeTrue(); +}); + +test('returns false for empty whitelist', function (): void { + $result = $this->service->isIpInWhitelist('192.168.1.1', []); + + expect($result)->toBeFalse(); +}); + +test('handles loopback addresses', function (): void { + expect($this->service->isIpInWhitelist('127.0.0.1', ['127.0.0.0/8']))->toBeTrue(); + expect($this->service->isIpInWhitelist('::1', ['::1']))->toBeTrue(); +}); + +test('handles private ranges', function (): void { + // RFC 1918 private ranges + expect($this->service->isIpInWhitelist('10.0.0.1', ['10.0.0.0/8']))->toBeTrue(); + expect($this->service->isIpInWhitelist('172.16.0.1', ['172.16.0.0/12']))->toBeTrue(); + expect($this->service->isIpInWhitelist('192.168.0.1', ['192.168.0.0/16']))->toBeTrue(); +}); + +test('handles link-local IPv6', function (): void { + expect($this->service->isIpInWhitelist('fe80::1', ['fe80::/10']))->toBeTrue(); +}); + +test('handles unique local IPv6', function (): void { + expect($this->service->isIpInWhitelist('fd00::1', ['fc00::/7']))->toBeTrue(); +}); + +test('rejects malformed CIDR', function (): void { + expect($this->service->ipMatchesCidr('192.168.1.1', '192.168.1.0'))->toBeFalse(); + expect($this->service->ipMatchesCidr('192.168.1.1', '192.168.1.0//'))->toBeFalse(); +}); + +test('handles multiple CIDR ranges in whitelist', function (): void { + $whitelist = [ + '10.0.0.0/8', + '172.16.0.0/12', + '192.168.0.0/16', + '2001:db8::/32', + ]; + + expect($this->service->isIpInWhitelist('10.1.2.3', $whitelist))->toBeTrue(); + expect($this->service->isIpInWhitelist('172.20.1.1', $whitelist))->toBeTrue(); + expect($this->service->isIpInWhitelist('192.168.100.50', $whitelist))->toBeTrue(); + expect($this->service->isIpInWhitelist('2001:db8:1234::1', $whitelist))->toBeTrue(); + expect($this->service->isIpInWhitelist('8.8.8.8', $whitelist))->toBeFalse(); +}); + +test('handles boundary IPs in CIDR range', function (): void { + // First and last IP in a /24 + expect($this->service->isIpInWhitelist('192.168.1.0', ['192.168.1.0/24']))->toBeTrue(); + expect($this->service->isIpInWhitelist('192.168.1.255', ['192.168.1.0/24']))->toBeTrue(); + + // Just outside the range + expect($this->service->isIpInWhitelist('192.168.0.255', ['192.168.1.0/24']))->toBeFalse(); + expect($this->service->isIpInWhitelist('192.168.2.0', ['192.168.1.0/24']))->toBeFalse(); +}); + +test('handles very large IPv6 ranges', function (): void { + // /16 gives an enormous number of addresses + expect($this->service->isIpInWhitelist('2001:db8::1', ['2001::/16']))->toBeTrue(); + expect($this->service->isIpInWhitelist('2001:ffff:ffff:ffff:ffff:ffff:ffff:ffff', ['2001::/16']))->toBeTrue(); + expect($this->service->isIpInWhitelist('2002::1', ['2001::/16']))->toBeFalse(); +});