From 49c862b6c10702157e79a84856a80cb050a94ccc Mon Sep 17 00:00:00 2001 From: Snider Date: Thu, 29 Jan 2026 13:20:58 +0000 Subject: [PATCH] feat(security): add API key IP whitelisting with CIDR support (P1-004) - P1-002: API key security tests verified (bcrypt, rotation) - P1-003: Webhook signature tests verified (HMAC-SHA256) - P1-004: IP whitelisting with IPv4/IPv6 CIDR support Co-Authored-By: Claude Opus 4.5 --- TODO.md | 51 ++- src/Api/Boot.php | 3 + src/Api/Database/Factories/ApiKeyFactory.php | 13 + src/Api/Middleware/AuthenticateApiKey.php | 11 + ...0000_add_allowed_ips_to_api_keys_table.php | 43 ++ src/Api/Models/ApiKey.php | 56 ++- src/Api/Services/IpRestrictionService.php | 308 +++++++++++++ .../Tests/Feature/ApiKeyIpWhitelistTest.php | 403 ++++++++++++++++++ 8 files changed, 866 insertions(+), 22 deletions(-) create mode 100644 src/Api/Migrations/2026_01_29_000000_add_allowed_ips_to_api_keys_table.php create mode 100644 src/Api/Services/IpRestrictionService.php create mode 100644 src/Api/Tests/Feature/ApiKeyIpWhitelistTest.php diff --git a/TODO.md b/TODO.md index fa18354..aee1831 100644 --- a/TODO.md +++ b/TODO.md @@ -4,22 +4,24 @@ ### High Priority -- [ ] **Test Coverage: API Key Security** - Test bcrypt hashing and rotation - - [ ] Test API key creation with bcrypt hashing - - [ ] Test API key authentication - - [ ] Test key rotation with grace period - - [ ] Test key revocation - - [ ] Test scoped key access - - **Estimated effort:** 3-4 hours +- [x] **Test Coverage: API Key Security** - Test bcrypt hashing and rotation + - [x] Test API key creation with bcrypt hashing + - [x] Test API key authentication + - [x] Test key rotation with grace period + - [x] Test key revocation + - [x] Test scoped key access + - **Completed:** 29 January 2026 + - **File:** `src/Api/Tests/Feature/ApiKeySecurityTest.php` -- [ ] **Test Coverage: Webhook System** - Test delivery and signatures - - [ ] Test webhook endpoint registration - - [ ] Test HMAC-SHA256 signature generation - - [ ] Test signature verification - - [ ] Test webhook delivery retry logic - - [ ] Test exponential backoff - - [ ] Test delivery status tracking - - **Estimated effort:** 4-5 hours +- [x] **Test Coverage: Webhook System** - Test delivery and signatures + - [x] Test webhook endpoint registration + - [x] Test HMAC-SHA256 signature generation + - [x] Test signature verification + - [x] Test webhook delivery retry logic + - [x] Test exponential backoff + - [x] Test delivery status tracking + - **Completed:** 29 January 2026 + - **File:** `src/Api/Tests/Feature/WebhookDeliveryTest.php` - [ ] **Test Coverage: Rate Limiting** - Test tier-based limits - [ ] Test per-tier rate limits @@ -141,12 +143,16 @@ ### High Priority -- [ ] **Security: API Key IP Whitelisting** - Restrict key usage - - [ ] Add allowed_ips column to api_keys - - [ ] Validate request IP against whitelist - - [ ] Test with IPv4 and IPv6 - - [ ] Add CIDR notation support - - **Estimated effort:** 3-4 hours +- [x] **Security: API Key IP Whitelisting** - Restrict key usage + - [x] Add allowed_ips column to api_keys + - [x] Validate request IP against whitelist + - [x] Test with IPv4 and IPv6 + - [x] Add CIDR notation support + - **Completed:** 29 January 2026 + - **Files:** + - `src/Api/Migrations/2026_01_29_000000_add_allowed_ips_to_api_keys_table.php` + - `src/Api/Services/IpRestrictionService.php` + - `src/Api/Tests/Feature/ApiKeyIpWhitelistTest.php` - [ ] **Security: Request Signing** - Prevent replay attacks - [ ] Implement timestamp validation @@ -242,5 +248,8 @@ - [x] **Rate Limiting** - Tier-based rate limits with usage alerts - [x] **OpenAPI Documentation** - Auto-generated API docs with Swagger/Scalar/ReDoc - [x] **Documentation** - Complete API package documentation +- [x] **API Key Security Tests** - Comprehensive bcrypt hashing and rotation tests (P1-002) +- [x] **Webhook System Signature Tests** - HMAC-SHA256 signature verification tests (P1-003) +- [x] **API Key IP Whitelisting** - allowed_ips column with IPv4/IPv6 and CIDR support (P1-004) *See `changelog/2026/jan/` for completed features.* diff --git a/src/Api/Boot.php b/src/Api/Boot.php index 9166ba9..904a679 100644 --- a/src/Api/Boot.php +++ b/src/Api/Boot.php @@ -60,6 +60,9 @@ class Boot extends ServiceProvider $this->app->singleton(Services\WebhookTemplateService::class); $this->app->singleton(Services\WebhookSecretRotationService::class); + // Register IP restriction service for API key whitelisting + $this->app->singleton(Services\IpRestrictionService::class); + // Register API Documentation provider $this->app->register(DocumentationServiceProvider::class); } diff --git a/src/Api/Database/Factories/ApiKeyFactory.php b/src/Api/Database/Factories/ApiKeyFactory.php index 36b6898..f992d8c 100644 --- a/src/Api/Database/Factories/ApiKeyFactory.php +++ b/src/Api/Database/Factories/ApiKeyFactory.php @@ -55,6 +55,7 @@ class ApiKeyFactory extends Factory 'prefix' => $prefix, 'scopes' => [ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE], 'server_scopes' => null, + 'allowed_ips' => null, 'last_used_at' => null, 'expires_at' => null, 'grace_period_ends_at' => null, @@ -219,6 +220,18 @@ class ApiKeyFactory extends Factory ]); } + /** + * Set IP whitelist restrictions. + * + * @param array|null $ips Array of IP addresses/CIDRs + */ + public function withAllowedIps(?array $ips): static + { + return $this->state(fn (array $attributes) => [ + 'allowed_ips' => $ips, + ]); + } + /** * Create a revoked (soft-deleted) key. */ diff --git a/src/Api/Middleware/AuthenticateApiKey.php b/src/Api/Middleware/AuthenticateApiKey.php index 059b44d..40b6fe9 100644 --- a/src/Api/Middleware/AuthenticateApiKey.php +++ b/src/Api/Middleware/AuthenticateApiKey.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Core\Api\Middleware; use Core\Api\Models\ApiKey; +use Core\Api\Services\IpRestrictionService; use Closure; use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\Response; @@ -59,6 +60,16 @@ class AuthenticateApiKey return $this->unauthorized('API key has expired'); } + // Check IP whitelist if restrictions are enabled + if ($apiKey->hasIpRestrictions()) { + $ipService = app(IpRestrictionService::class); + $requestIp = $request->ip(); + + if (! $ipService->isIpAllowed($requestIp, $apiKey->getAllowedIps() ?? [])) { + return $this->forbidden('IP address not allowed for this API key'); + } + } + // Check scope if required if ($scope !== null && ! $apiKey->hasScope($scope)) { return $this->forbidden("API key missing required scope: {$scope}"); diff --git a/src/Api/Migrations/2026_01_29_000000_add_allowed_ips_to_api_keys_table.php b/src/Api/Migrations/2026_01_29_000000_add_allowed_ips_to_api_keys_table.php new file mode 100644 index 0000000..0a0803f --- /dev/null +++ b/src/Api/Migrations/2026_01_29_000000_add_allowed_ips_to_api_keys_table.php @@ -0,0 +1,43 @@ +json('allowed_ips') + ->nullable() + ->after('server_scopes') + ->comment('IP whitelist: null=all IPs allowed, ["192.168.1.0/24"]=specific'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('api_keys', function (Blueprint $table) { + $table->dropColumn('allowed_ips'); + }); + } +}; diff --git a/src/Api/Models/ApiKey.php b/src/Api/Models/ApiKey.php index 18d8462..0ee34e3 100644 --- a/src/Api/Models/ApiKey.php +++ b/src/Api/Models/ApiKey.php @@ -64,6 +64,7 @@ class ApiKey extends Model 'prefix', 'scopes', 'server_scopes', + 'allowed_ips', 'last_used_at', 'expires_at', 'grace_period_ends_at', @@ -73,6 +74,7 @@ class ApiKey extends Model protected $casts = [ 'scopes' => 'array', 'server_scopes' => 'array', + 'allowed_ips' => 'array', 'last_used_at' => 'datetime', 'expires_at' => 'datetime', 'grace_period_ends_at' => 'datetime', @@ -208,9 +210,10 @@ class ApiKey extends Model $this->expires_at ); - // Copy server scopes to new key + // Copy server scopes and IP restrictions to new key $result['api_key']->update([ 'server_scopes' => $this->server_scopes, + 'allowed_ips' => $this->allowed_ips, 'rotated_from_id' => $this->id, ]); @@ -312,6 +315,57 @@ class ApiKey extends Model return $this->server_scopes; } + /** + * Check if this key has IP restrictions enabled. + */ + public function hasIpRestrictions(): bool + { + return ! empty($this->allowed_ips); + } + + /** + * Get the allowed IPs list (null = all IPs allowed). + * + * @return array|null + */ + public function getAllowedIps(): ?array + { + return $this->allowed_ips; + } + + /** + * Update the IP whitelist. + * + * @param array|null $ips Array of IP addresses/CIDRs, or null to allow all + */ + public function updateAllowedIps(?array $ips): void + { + $this->update(['allowed_ips' => $ips]); + } + + /** + * Add an IP or CIDR to the whitelist. + */ + public function addAllowedIp(string $ipOrCidr): void + { + $whitelist = $this->allowed_ips ?? []; + + if (! in_array($ipOrCidr, $whitelist, true)) { + $whitelist[] = $ipOrCidr; + $this->update(['allowed_ips' => $whitelist]); + } + } + + /** + * Remove an IP or CIDR from the whitelist. + */ + public function removeAllowedIp(string $ipOrCidr): void + { + $whitelist = $this->allowed_ips ?? []; + $whitelist = array_values(array_filter($whitelist, fn ($entry) => $entry !== $ipOrCidr)); + $this->update(['allowed_ips' => empty($whitelist) ? null : $whitelist]); + } + /** * Revoke this API key. */ diff --git a/src/Api/Services/IpRestrictionService.php b/src/Api/Services/IpRestrictionService.php new file mode 100644 index 0000000..f2cb576 --- /dev/null +++ b/src/Api/Services/IpRestrictionService.php @@ -0,0 +1,308 @@ + $whitelist + */ + public function isIpAllowed(string $ip, array $whitelist): bool + { + $ip = trim($ip); + + // Empty whitelist means no restrictions + if (empty($whitelist)) { + return true; + } + + // Validate the request IP is a valid IP address + if (! filter_var($ip, FILTER_VALIDATE_IP)) { + return false; + } + + foreach ($whitelist as $entry) { + $entry = trim($entry); + + if (empty($entry)) { + continue; + } + + // Check for CIDR notation + if (str_contains($entry, '/')) { + if ($this->ipMatchesCidr($ip, $entry)) { + return true; + } + } else { + // Exact IP match (normalise both for comparison) + if ($this->normaliseIp($ip) === $this->normaliseIp($entry)) { + return true; + } + } + } + + return false; + } + + /** + * Check if an IP matches a CIDR range. + */ + public function ipMatchesCidr(string $ip, string $cidr): bool + { + $parts = explode('/', $cidr, 2); + + if (count($parts) !== 2) { + return false; + } + + [$range, $prefix] = $parts; + $prefix = (int) $prefix; + + // Validate both IPs + if (! filter_var($ip, FILTER_VALIDATE_IP)) { + return false; + } + + if (! filter_var($range, FILTER_VALIDATE_IP)) { + return false; + } + + $isIpv6 = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6); + $isRangeIpv6 = filter_var($range, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6); + + // IP version must match + if ($isIpv6 !== $isRangeIpv6) { + return false; + } + + if ($isIpv6) { + return $this->ipv6MatchesCidr($ip, $range, $prefix); + } + + return $this->ipv4MatchesCidr($ip, $range, $prefix); + } + + /** + * Check if an IPv4 address matches a CIDR range. + */ + protected function ipv4MatchesCidr(string $ip, string $range, int $prefix): bool + { + // Validate prefix length + if ($prefix < 0 || $prefix > 32) { + return false; + } + + $ipLong = ip2long($ip); + $rangeLong = ip2long($range); + + if ($ipLong === false || $rangeLong === false) { + return false; + } + + // Create the subnet mask + $mask = -1 << (32 - $prefix); + + // Apply mask and compare + return ($ipLong & $mask) === ($rangeLong & $mask); + } + + /** + * Check if an IPv6 address matches a CIDR range. + */ + protected function ipv6MatchesCidr(string $ip, string $range, int $prefix): bool + { + // Validate prefix length + if ($prefix < 0 || $prefix > 128) { + return false; + } + + // Convert to binary representation + $ipBin = $this->ipv6ToBinary($ip); + $rangeBin = $this->ipv6ToBinary($range); + + if ($ipBin === null || $rangeBin === null) { + return false; + } + + // Compare the first $prefix bits + $prefixBytes = (int) floor($prefix / 8); + $remainingBits = $prefix % 8; + + // Compare full bytes + if (substr($ipBin, 0, $prefixBytes) !== substr($rangeBin, 0, $prefixBytes)) { + return false; + } + + // Compare remaining bits if any + if ($remainingBits > 0) { + $mask = 0xFF << (8 - $remainingBits); + $ipByte = ord($ipBin[$prefixBytes]); + $rangeByte = ord($rangeBin[$prefixBytes]); + + if (($ipByte & $mask) !== ($rangeByte & $mask)) { + return false; + } + } + + return true; + } + + /** + * Convert an IPv6 address to its binary representation. + */ + protected function ipv6ToBinary(string $ip): ?string + { + $packed = inet_pton($ip); + + if ($packed === false) { + return null; + } + + return $packed; + } + + /** + * Normalise an IP address for comparison. + * + * - IPv4: No change needed + * - IPv6: Expand to full form for consistent comparison + */ + public function normaliseIp(string $ip): string + { + $ip = trim($ip); + + // Try to pack and unpack for normalisation + $packed = inet_pton($ip); + + if ($packed === false) { + return $ip; // Return original if invalid + } + + // inet_ntop will return normalised form + $normalised = inet_ntop($packed); + + return $normalised !== false ? $normalised : $ip; + } + + /** + * Validate an IP address or CIDR notation. + * + * @return array{valid: bool, error: ?string} + */ + public function validateEntry(string $entry): array + { + $entry = trim($entry); + + if (empty($entry)) { + return ['valid' => false, 'error' => 'Empty entry']; + } + + // Check for CIDR notation + if (str_contains($entry, '/')) { + return $this->validateCidr($entry); + } + + // Validate as plain IP + if (! filter_var($entry, FILTER_VALIDATE_IP)) { + return ['valid' => false, 'error' => 'Invalid IP address']; + } + + return ['valid' => true, 'error' => null]; + } + + /** + * Validate CIDR notation. + * + * @return array{valid: bool, error: ?string} + */ + public function validateCidr(string $cidr): array + { + $parts = explode('/', $cidr, 2); + + if (count($parts) !== 2) { + return ['valid' => false, 'error' => 'Invalid CIDR notation']; + } + + [$ip, $prefix] = $parts; + + // Validate IP portion + if (! filter_var($ip, FILTER_VALIDATE_IP)) { + return ['valid' => false, 'error' => 'Invalid IP address in CIDR']; + } + + // Validate prefix is numeric + if (! is_numeric($prefix)) { + return ['valid' => false, 'error' => 'Invalid prefix length']; + } + + $prefix = (int) $prefix; + $isIpv6 = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6); + + // Validate prefix range + if ($isIpv6) { + if ($prefix < 0 || $prefix > 128) { + return ['valid' => false, 'error' => 'IPv6 prefix must be between 0 and 128']; + } + } else { + if ($prefix < 0 || $prefix > 32) { + return ['valid' => false, 'error' => 'IPv4 prefix must be between 0 and 32']; + } + } + + return ['valid' => true, 'error' => null]; + } + + /** + * Parse a multi-line string of IPs/CIDRs into an array. + * + * @return array{entries: array, errors: array} + */ + public function parseWhitelistInput(string $input): array + { + $lines = preg_split('/[\r\n,]+/', $input); + $entries = []; + $errors = []; + + foreach ($lines as $line) { + $line = trim($line); + + if (empty($line)) { + continue; + } + + // Skip comments + if (str_starts_with($line, '#')) { + continue; + } + + $validation = $this->validateEntry($line); + + if ($validation['valid']) { + $entries[] = $line; + } else { + $errors[] = "{$line}: {$validation['error']}"; + } + } + + return [ + 'entries' => $entries, + 'errors' => $errors, + ]; + } +} diff --git a/src/Api/Tests/Feature/ApiKeyIpWhitelistTest.php b/src/Api/Tests/Feature/ApiKeyIpWhitelistTest.php new file mode 100644 index 0000000..b67a592 --- /dev/null +++ b/src/Api/Tests/Feature/ApiKeyIpWhitelistTest.php @@ -0,0 +1,403 @@ +user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(); + $this->workspace->users()->attach($this->user->id, [ + 'role' => 'owner', + 'is_default' => true, + ]); + $this->ipService = app(IpRestrictionService::class); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// IP Restriction Service - IPv4 +// ───────────────────────────────────────────────────────────────────────────── + +describe('IP Restriction Service - IPv4', function () { + it('allows IP when whitelist is empty', function () { + expect($this->ipService->isIpAllowed('192.168.1.1', []))->toBeTrue(); + }); + + it('matches exact IPv4 address', function () { + $whitelist = ['192.168.1.1', '10.0.0.1']; + + expect($this->ipService->isIpAllowed('192.168.1.1', $whitelist))->toBeTrue(); + expect($this->ipService->isIpAllowed('10.0.0.1', $whitelist))->toBeTrue(); + expect($this->ipService->isIpAllowed('192.168.1.2', $whitelist))->toBeFalse(); + }); + + it('matches IPv4 CIDR /24 range', function () { + $whitelist = ['192.168.1.0/24']; + + expect($this->ipService->isIpAllowed('192.168.1.0', $whitelist))->toBeTrue(); + expect($this->ipService->isIpAllowed('192.168.1.1', $whitelist))->toBeTrue(); + expect($this->ipService->isIpAllowed('192.168.1.255', $whitelist))->toBeTrue(); + expect($this->ipService->isIpAllowed('192.168.2.1', $whitelist))->toBeFalse(); + }); + + it('matches IPv4 CIDR /16 range', function () { + $whitelist = ['10.0.0.0/16']; + + expect($this->ipService->isIpAllowed('10.0.0.1', $whitelist))->toBeTrue(); + expect($this->ipService->isIpAllowed('10.0.255.255', $whitelist))->toBeTrue(); + expect($this->ipService->isIpAllowed('10.1.0.1', $whitelist))->toBeFalse(); + }); + + it('matches IPv4 CIDR /32 single host', function () { + $whitelist = ['192.168.1.100/32']; + + expect($this->ipService->isIpAllowed('192.168.1.100', $whitelist))->toBeTrue(); + expect($this->ipService->isIpAllowed('192.168.1.101', $whitelist))->toBeFalse(); + }); + + it('matches IPv4 CIDR /8 class A range', function () { + $whitelist = ['10.0.0.0/8']; + + expect($this->ipService->isIpAllowed('10.0.0.1', $whitelist))->toBeTrue(); + expect($this->ipService->isIpAllowed('10.255.255.255', $whitelist))->toBeTrue(); + expect($this->ipService->isIpAllowed('11.0.0.1', $whitelist))->toBeFalse(); + }); + + it('rejects invalid IPv4 addresses', function () { + $whitelist = ['192.168.1.0/24']; + + expect($this->ipService->isIpAllowed('invalid', $whitelist))->toBeFalse(); + expect($this->ipService->isIpAllowed('256.256.256.256', $whitelist))->toBeFalse(); + expect($this->ipService->isIpAllowed('', $whitelist))->toBeFalse(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// IP Restriction Service - IPv6 +// ───────────────────────────────────────────────────────────────────────────── + +describe('IP Restriction Service - IPv6', function () { + it('matches exact IPv6 address', function () { + $whitelist = ['::1', '2001:db8::1']; + + expect($this->ipService->isIpAllowed('::1', $whitelist))->toBeTrue(); + expect($this->ipService->isIpAllowed('2001:db8::1', $whitelist))->toBeTrue(); + expect($this->ipService->isIpAllowed('2001:db8::2', $whitelist))->toBeFalse(); + }); + + it('normalises IPv6 for comparison', function () { + $whitelist = ['2001:db8:0000:0000:0000:0000:0000:0001']; + + // Shortened form should match expanded form + expect($this->ipService->isIpAllowed('2001:db8::1', $whitelist))->toBeTrue(); + }); + + it('matches IPv6 CIDR /64 range', function () { + $whitelist = ['2001:db8::/64']; + + expect($this->ipService->isIpAllowed('2001:db8::1', $whitelist))->toBeTrue(); + expect($this->ipService->isIpAllowed('2001:db8::ffff', $whitelist))->toBeTrue(); + expect($this->ipService->isIpAllowed('2001:db8:0:1::1', $whitelist))->toBeFalse(); + }); + + it('matches IPv6 CIDR /32 range', function () { + $whitelist = ['2001:db8::/32']; + + expect($this->ipService->isIpAllowed('2001:db8::1', $whitelist))->toBeTrue(); + expect($this->ipService->isIpAllowed('2001:db8:ffff::1', $whitelist))->toBeTrue(); + expect($this->ipService->isIpAllowed('2001:db9::1', $whitelist))->toBeFalse(); + }); + + it('matches IPv6 loopback', function () { + $whitelist = ['::1/128']; + + expect($this->ipService->isIpAllowed('::1', $whitelist))->toBeTrue(); + expect($this->ipService->isIpAllowed('::2', $whitelist))->toBeFalse(); + }); + + it('does not match IPv4 against IPv6 CIDR', function () { + $whitelist = ['2001:db8::/32']; + + expect($this->ipService->isIpAllowed('192.168.1.1', $whitelist))->toBeFalse(); + }); + + it('does not match IPv6 against IPv4 CIDR', function () { + $whitelist = ['192.168.1.0/24']; + + expect($this->ipService->isIpAllowed('2001:db8::1', $whitelist))->toBeFalse(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// IP Restriction Service - Validation +// ───────────────────────────────────────────────────────────────────────────── + +describe('IP Restriction Service - Validation', function () { + it('validates correct IPv4 addresses', function () { + $result = $this->ipService->validateEntry('192.168.1.1'); + + expect($result['valid'])->toBeTrue(); + expect($result['error'])->toBeNull(); + }); + + it('validates correct IPv6 addresses', function () { + $result = $this->ipService->validateEntry('2001:db8::1'); + + expect($result['valid'])->toBeTrue(); + expect($result['error'])->toBeNull(); + }); + + it('validates correct IPv4 CIDR', function () { + $result = $this->ipService->validateEntry('192.168.1.0/24'); + + expect($result['valid'])->toBeTrue(); + expect($result['error'])->toBeNull(); + }); + + it('validates correct IPv6 CIDR', function () { + $result = $this->ipService->validateEntry('2001:db8::/32'); + + expect($result['valid'])->toBeTrue(); + expect($result['error'])->toBeNull(); + }); + + it('rejects invalid IP addresses', function () { + $result = $this->ipService->validateEntry('not-an-ip'); + + expect($result['valid'])->toBeFalse(); + expect($result['error'])->toBe('Invalid IP address'); + }); + + it('rejects invalid CIDR prefix for IPv4', function () { + $result = $this->ipService->validateEntry('192.168.1.0/33'); + + expect($result['valid'])->toBeFalse(); + expect($result['error'])->toBe('IPv4 prefix must be between 0 and 32'); + }); + + it('rejects invalid CIDR prefix for IPv6', function () { + $result = $this->ipService->validateEntry('2001:db8::/129'); + + expect($result['valid'])->toBeFalse(); + expect($result['error'])->toBe('IPv6 prefix must be between 0 and 128'); + }); + + it('rejects empty entries', function () { + $result = $this->ipService->validateEntry(''); + + expect($result['valid'])->toBeFalse(); + expect($result['error'])->toBe('Empty entry'); + }); + + it('parses multi-line whitelist input', function () { + $input = "192.168.1.1\n10.0.0.0/8\n# Comment line\n2001:db8::1\ninvalid-ip"; + + $result = $this->ipService->parseWhitelistInput($input); + + expect($result['entries'])->toBe(['192.168.1.1', '10.0.0.0/8', '2001:db8::1']); + expect($result['errors'])->toHaveCount(1); + expect($result['errors'][0])->toContain('invalid-ip'); + }); + + it('handles comma-separated whitelist input', function () { + $input = '192.168.1.1, 10.0.0.1, 172.16.0.0/12'; + + $result = $this->ipService->parseWhitelistInput($input); + + expect($result['entries'])->toBe(['192.168.1.1', '10.0.0.1', '172.16.0.0/12']); + expect($result['errors'])->toBeEmpty(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// API Key IP Whitelist Model Methods +// ───────────────────────────────────────────────────────────────────────────── + +describe('API Key IP Whitelist Model', function () { + it('reports no restrictions when allowed_ips is null', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'No Restrictions Key' + ); + + expect($result['api_key']->hasIpRestrictions())->toBeFalse(); + expect($result['api_key']->getAllowedIps())->toBeNull(); + }); + + it('reports no restrictions when allowed_ips is empty', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Empty Whitelist Key' + ); + $result['api_key']->update(['allowed_ips' => []]); + + expect($result['api_key']->fresh()->hasIpRestrictions())->toBeFalse(); + }); + + it('reports restrictions when allowed_ips has entries', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Restricted Key' + ); + $result['api_key']->update(['allowed_ips' => ['192.168.1.0/24']]); + + $key = $result['api_key']->fresh(); + expect($key->hasIpRestrictions())->toBeTrue(); + expect($key->getAllowedIps())->toBe(['192.168.1.0/24']); + }); + + it('updates allowed IPs', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Update IPs Key' + ); + + $result['api_key']->updateAllowedIps(['10.0.0.0/8', '192.168.1.1']); + + expect($result['api_key']->fresh()->getAllowedIps())->toBe(['10.0.0.0/8', '192.168.1.1']); + }); + + it('adds IP to whitelist', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Add IP Key' + ); + $result['api_key']->update(['allowed_ips' => ['192.168.1.1']]); + + $result['api_key']->addAllowedIp('10.0.0.1'); + + expect($result['api_key']->fresh()->getAllowedIps())->toBe(['192.168.1.1', '10.0.0.1']); + }); + + it('does not add duplicate IPs', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Duplicate IP Key' + ); + $result['api_key']->update(['allowed_ips' => ['192.168.1.1']]); + + $result['api_key']->addAllowedIp('192.168.1.1'); + + expect($result['api_key']->fresh()->getAllowedIps())->toBe(['192.168.1.1']); + }); + + it('removes IP from whitelist', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Remove IP Key' + ); + $result['api_key']->update(['allowed_ips' => ['192.168.1.1', '10.0.0.1']]); + + $result['api_key']->removeAllowedIp('192.168.1.1'); + + expect($result['api_key']->fresh()->getAllowedIps())->toBe(['10.0.0.1']); + }); + + it('sets allowed_ips to null when removing last IP', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Remove Last IP Key' + ); + $result['api_key']->update(['allowed_ips' => ['192.168.1.1']]); + + $result['api_key']->removeAllowedIp('192.168.1.1'); + + expect($result['api_key']->fresh()->getAllowedIps())->toBeNull(); + expect($result['api_key']->fresh()->hasIpRestrictions())->toBeFalse(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// API Key Rotation with IP Whitelist +// ───────────────────────────────────────────────────────────────────────────── + +describe('API Key Rotation with IP Whitelist', function () { + it('preserves IP whitelist during rotation', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'IP Restricted Key' + ); + $result['api_key']->update(['allowed_ips' => ['192.168.1.0/24', '10.0.0.1']]); + + $rotated = $result['api_key']->fresh()->rotate(); + + expect($rotated['api_key']->getAllowedIps())->toBe(['192.168.1.0/24', '10.0.0.1']); + }); + + it('preserves empty IP whitelist during rotation', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'No Restrictions Key' + ); + + $rotated = $result['api_key']->rotate(); + + expect($rotated['api_key']->getAllowedIps())->toBeNull(); + expect($rotated['api_key']->hasIpRestrictions())->toBeFalse(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// API Key Factory IP Whitelist +// ───────────────────────────────────────────────────────────────────────────── + +describe('API Key Factory IP Whitelist', function () { + it('creates keys with IP whitelist via factory', function () { + $key = ApiKey::factory() + ->for($this->workspace) + ->for($this->user) + ->withAllowedIps(['192.168.1.0/24', '::1']) + ->create(); + + expect($key->hasIpRestrictions())->toBeTrue(); + expect($key->getAllowedIps())->toBe(['192.168.1.0/24', '::1']); + }); + + it('creates keys without IP restrictions by default', function () { + $key = ApiKey::factory() + ->for($this->workspace) + ->for($this->user) + ->create(); + + expect($key->hasIpRestrictions())->toBeFalse(); + expect($key->getAllowedIps())->toBeNull(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Mixed IP Versions +// ───────────────────────────────────────────────────────────────────────────── + +describe('Mixed IP Versions in Whitelist', function () { + it('handles mixed IPv4 and IPv6 entries', function () { + $whitelist = ['192.168.1.0/24', '2001:db8::/32', '10.0.0.1', '::1']; + + // IPv4 matching + expect($this->ipService->isIpAllowed('192.168.1.100', $whitelist))->toBeTrue(); + expect($this->ipService->isIpAllowed('10.0.0.1', $whitelist))->toBeTrue(); + expect($this->ipService->isIpAllowed('172.16.0.1', $whitelist))->toBeFalse(); + + // IPv6 matching + expect($this->ipService->isIpAllowed('2001:db8::1', $whitelist))->toBeTrue(); + expect($this->ipService->isIpAllowed('::1', $whitelist))->toBeTrue(); + expect($this->ipService->isIpAllowed('2001:db9::1', $whitelist))->toBeFalse(); + }); +});