diff --git a/.env.example b/.env.example index 9cea110..33ae4ca 100644 --- a/.env.example +++ b/.env.example @@ -70,10 +70,20 @@ MICROSOFT_CLIENT_SECRET= MICROSOFT_REDIRECT_URI="${APP_URL}/auth/microsoft/callback" MICROSOFT_TENANT_ID=common +# ICE Candidate / TURN Server Configurations +ICE_CANDIDATE_PROVIDER=metered +ICE_FALLBACK_PROVIDER=convexsol + # Metered TURN Server Credentials METERED_URL= METERED_KEY= +# ConvexSol TURN / STUN Server Credentials +CONVEXSOL_USERNAME= +CONVEXSOL_PASSWORD= +CONVEXSOL_TURN_URLS= +CONVEXSOL_STUN_URLS= + # Interview Configurations MAX_INTERVIEWER=2 INTERVIEW_RECORDINGS_FOLDER=uploads/candidate_recordings diff --git a/app/Contracts/IceCandidateProviderInterface.php b/app/Contracts/IceCandidateProviderInterface.php new file mode 100644 index 0000000..5848f6a --- /dev/null +++ b/app/Contracts/IceCandidateProviderInterface.php @@ -0,0 +1,13 @@ + $urls + */ + public function __construct( + public readonly string|array $urls, + public readonly ?string $username = null, + public readonly ?string $credential = null, + public readonly ?string $credentialType = null, + ) {} + + public static function fromArray(array $data): self + { + $rawUrls = $data['urls'] ?? $data['url'] ?? ''; + + return new self( + urls: is_array($rawUrls) ? array_values($rawUrls) : (string) $rawUrls, + username: isset($data['username']) ? (string) $data['username'] : null, + credential: isset($data['credential']) ? (string) $data['credential'] : (isset($data['password']) ? (string) $data['password'] : null), + credentialType: isset($data['credentialType']) ? (string) $data['credentialType'] : null, + ); + } + + public function toArray(): array + { + return array_filter([ + 'urls' => $this->urls, + 'username' => $this->username, + 'credential' => $this->credential, + 'credentialType' => $this->credentialType, + ], fn ($value) => $value !== null); + } + + public function jsonSerialize(): array + { + return $this->toArray(); + } +} diff --git a/app/DTOs/IceCandidatesDto.php b/app/DTOs/IceCandidatesDto.php new file mode 100644 index 0000000..2d62830 --- /dev/null +++ b/app/DTOs/IceCandidatesDto.php @@ -0,0 +1,69 @@ + $servers + */ + public function __construct( + public readonly array $servers = [] + ) {} + + public static function fromArray(array $items): self + { + $servers = []; + foreach ($items as $item) { + if ($item instanceof IceCandidateDto) { + $servers[] = $item; + } elseif (is_array($item)) { + $servers[] = IceCandidateDto::fromArray($item); + } + } + + return new self($servers); + } + + public static function defaultStun(): self + { + return new self([ + new IceCandidateDto(urls: [ + 'stun:stun.l.google.com:19302', + 'stun:stun.cloudflare.com:3478', + ]), + ]); + } + + public function toArray(): array + { + return array_map(fn (IceCandidateDto $server) => $server->toArray(), $this->servers); + } + + public function jsonSerialize(): array + { + return $this->toArray(); + } + + public function count(): int + { + return count($this->servers); + } + + public function isEmpty(): bool + { + return empty($this->servers); + } + + public function getIterator(): Traversable + { + return new ArrayIterator($this->servers); + } +} diff --git a/app/Http/Controllers/IceServerController.php b/app/Http/Controllers/IceServerController.php index 10a652b..5507d3d 100644 --- a/app/Http/Controllers/IceServerController.php +++ b/app/Http/Controllers/IceServerController.php @@ -1,65 +1,36 @@ has('candidate_interview_id')) { + if (! Auth::check() && ! session()->has('candidate_interview_id')) { return response()->json(['error' => 'Unauthenticated access.'], 401); } try { - $baseUrl = config( - "services.metered.url", - ); - $apiKey = config( - "services.metered.key", - ); - - $url = $baseUrl; - if ($apiKey && !str_contains($url, "apiKey=")) { - $separator = str_contains($url, "?") ? "&" : "?"; - $url .= $separator . "apiKey=" . urlencode($apiKey); - } - - $data = Cache::remember('metered_credentials_cache', now()->addMinute(10), function() use($url) { - $response = Http::timeout(5)->get($url); - if(!$response->successful()) - { - Log::error( - "Metered API error response", - [ - "status" => $response->status(), - "body" => $response->body(), - ], - ); - - throw new \RuntimeException('Metered API Error'); - } - - return $response->json(); - }); - return response()->json($data); + $candidates = $this->iceCandidateProvider->getIceCandidates(); + return new IceCandidatesResource($candidates); } catch (\Throwable $e) { - Log::error( - "ICE servers fetch exception: " . $e->getMessage(), - ); + Log::error('ICE servers fetch exception: '.$e->getMessage()); - return response()->json( - [ - "error" => "An error occurred while fetching ICE servers.", - "message" => $e->getMessage(), - ], - 500, - ); + return response()->json([ + 'error' => 'An error occurred while fetching ICE servers.', + 'message' => $e->getMessage(), + ], 500); } } } diff --git a/app/Http/Resources/Interview/IceCandidateResource.php b/app/Http/Resources/Interview/IceCandidateResource.php new file mode 100644 index 0000000..7c7791e --- /dev/null +++ b/app/Http/Resources/Interview/IceCandidateResource.php @@ -0,0 +1,40 @@ + + */ + public function toArray(Request $request): array + { + if ($this->resource instanceof IceCandidateDto) { + return $this->resource->toArray(); + } + + if (is_array($this->resource)) { + return array_filter([ + 'urls' => $this->resource['urls'] ?? $this->resource['url'] ?? null, + 'username' => $this->resource['username'] ?? null, + 'credential' => $this->resource['credential'] ?? $this->resource['password'] ?? null, + 'credentialType' => $this->resource['credentialType'] ?? null, + ], fn ($value) => $value !== null); + } + + return (array) $this->resource; + } +} diff --git a/app/Http/Resources/Interview/IceCandidatesResource.php b/app/Http/Resources/Interview/IceCandidatesResource.php new file mode 100644 index 0000000..99c5e0d --- /dev/null +++ b/app/Http/Resources/Interview/IceCandidatesResource.php @@ -0,0 +1,31 @@ +> + */ + public function toArray(Request $request): array + { + if ($this->resource instanceof IceCandidatesDto) { + return $this->resource->toArray(); + } + + return (array) $this->resource; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index e7ea0d7..9a78be2 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -5,6 +5,7 @@ use Illuminate\Support\ServiceProvider; use Illuminate\Support\Facades\Event; +use Illuminate\Support\Facades\URL; use SocialiteProviders\Manager\SocialiteWasCalled; use SocialiteProviders\Microsoft\MicrosoftExtendSocialite; @@ -23,6 +24,14 @@ public function register(): void */ public function boot(): void { + if (request()->server('HTTP_X_FORWARDED_PROTO') === 'https' || request()->isSecure()) { + URL::forceScheme('https'); + + if ($forwardedHost = request()->server('HTTP_X_FORWARDED_HOST')) { + URL::forceRootUrl("https://{$forwardedHost}"); + } + } + Event::listen( SocialiteWasCalled::class, MicrosoftExtendSocialite::class diff --git a/app/Providers/IceCandidateServiceProvider.php b/app/Providers/IceCandidateServiceProvider.php new file mode 100644 index 0000000..7119649 --- /dev/null +++ b/app/Providers/IceCandidateServiceProvider.php @@ -0,0 +1,38 @@ +app->singleton(IceCandidateManager::class, function ($app) { + return new IceCandidateManager($app); + }); + + $this->app->bind(IceCandidateProviderInterface::class, function ($app) { + return $app->make(IceCandidateManager::class); + }); + } + + /** + * Get the services provided by the provider. + * + * @return array + */ + public function provides(): array + { + return [ + IceCandidateManager::class, + IceCandidateProviderInterface::class, + ]; + } +} diff --git a/app/Services/IceCandidate/IceCandidateManager.php b/app/Services/IceCandidate/IceCandidateManager.php new file mode 100644 index 0000000..e6b37c7 --- /dev/null +++ b/app/Services/IceCandidate/IceCandidateManager.php @@ -0,0 +1,85 @@ +config->get('services.ice.default', 'metered'); + } + + public function getFallbackDriver(): ?string + { + return $this->config->get('services.ice.fallback', 'convexsol'); + } + + /** + * Retrieve ICE candidates with automatic failover and structured logging. + */ + public function getIceCandidates(): IceCandidatesDto + { + $primaryDriver = $this->getDefaultDriver(); + + try { + return $this->driver($primaryDriver)->getIceCandidates(); + } catch (Throwable $primaryException) { + $fallbackDriver = $this->getFallbackDriver(); + + if ($fallbackDriver && $fallbackDriver !== $primaryDriver) { + Log::warning("Primary ICE candidate provider [{$primaryDriver}] failed: {$primaryException->getMessage()}. Falling back to [{$fallbackDriver}].", [ + 'primary_driver' => $primaryDriver, + 'fallback_driver' => $fallbackDriver, + 'error' => $primaryException->getMessage(), + ]); + + try { + return $this->driver($fallbackDriver)->getIceCandidates(); + } catch (Throwable $fallbackException) { + Log::error("Fallback ICE candidate provider [{$fallbackDriver}] also failed: {$fallbackException->getMessage()}. Using default STUN servers.", [ + 'primary_error' => $primaryException->getMessage(), + 'fallback_error' => $fallbackException->getMessage(), + ]); + + return IceCandidatesDto::defaultStun(); + } + } + + Log::error("ICE candidate provider [{$primaryDriver}] failed without fallback: {$primaryException->getMessage()}. Using default STUN servers.", [ + 'error' => $primaryException->getMessage(), + ]); + + return IceCandidatesDto::defaultStun(); + } + } + + protected function createMeteredDriver(): IceCandidateProviderInterface + { + $config = $this->config->get('services.ice.providers.metered', [ + 'url' => $this->config->get('services.metered.url'), + 'key' => $this->config->get('services.metered.key'), + ]); + + return new MeteredIceCandidateProvider($config); + } + + protected function createConvexsolDriver(): IceCandidateProviderInterface + { + $config = $this->config->get('services.ice.providers.convexsol', [ + 'username' => $this->config->get('services.convexsol.username'), + 'password' => $this->config->get('services.convexsol.password'), + 'turn_urls' => $this->config->get('services.convexsol.turn_urls'), + 'stun_urls' => $this->config->get('services.convexsol.stun_urls'), + ]); + + return new ConvexSolIceCandidateProvider($config); + } +} diff --git a/app/Services/IceCandidate/Providers/ConvexSolIceCandidateProvider.php b/app/Services/IceCandidate/Providers/ConvexSolIceCandidateProvider.php new file mode 100644 index 0000000..7a726f8 --- /dev/null +++ b/app/Services/IceCandidate/Providers/ConvexSolIceCandidateProvider.php @@ -0,0 +1,61 @@ +config)); + $cacheTtl = (int) ($this->config['cache_ttl'] ?? 600); + + $servers = Cache::remember($cacheKey, now()->addSeconds($cacheTtl), function () { + $list = []; + + // 1. STUN servers + $stunUrls = $this->config['stun_urls'] ?? ['stun:stun.convexsol.com:3478', 'stun:stun.l.google.com:19302']; + if (is_string($stunUrls)) { + $stunUrls = array_filter(array_map('trim', explode(',', $stunUrls))); + } + if (! empty($stunUrls)) { + $list[] = [ + 'urls' => array_values($stunUrls), + ]; + } + + // 2. TURN servers with username and password + $turnUrls = $this->config['turn_urls'] ?? ['turn:turn.convexsol.com:3478', 'turns:turn.convexsol.com:5349']; + if (is_string($turnUrls)) { + $turnUrls = array_filter(array_map('trim', explode(',', $turnUrls))); + } + + $username = $this->config['username'] ?? null; + $password = $this->config['password'] ?? null; + + if (! empty($turnUrls)) { + $turnEntry = [ + 'urls' => array_values($turnUrls), + ]; + if ($username !== null) { + $turnEntry['username'] = $username; + } + if ($password !== null) { + $turnEntry['credential'] = $password; + } + $list[] = $turnEntry; + } + + return $list; + }); + + return IceCandidatesDto::fromArray($servers); + } +} diff --git a/app/Services/IceCandidate/Providers/MeteredIceCandidateProvider.php b/app/Services/IceCandidate/Providers/MeteredIceCandidateProvider.php new file mode 100644 index 0000000..18fefa4 --- /dev/null +++ b/app/Services/IceCandidate/Providers/MeteredIceCandidateProvider.php @@ -0,0 +1,57 @@ +config['url'] ?? config('services.metered.url'); + $apiKey = $this->config['key'] ?? config('services.metered.key'); + $timeout = (int) ($this->config['timeout'] ?? 5); + $cacheTtl = (int) ($this->config['cache_ttl'] ?? 600); + + if (empty($baseUrl)) { + throw new RuntimeException('Metered ICE provider URL is not configured.'); + } + + $url = $baseUrl; + if ($apiKey && ! str_contains($url, 'apiKey=')) { + $separator = str_contains($url, '?') ? '&' : '?'; + $url .= $separator.'apiKey='.urlencode($apiKey); + } + + $cacheKey = 'metered_credentials_cache_'.md5($url); + + $data = Cache::remember($cacheKey, now()->addSeconds($cacheTtl), function () use ($url, $timeout) { + $response = Http::timeout($timeout)->get($url); + + if (! $response->successful()) { + Log::error('Metered API error response', [ + 'status' => $response->status(), + 'body' => $response->body(), + ]); + + throw new RuntimeException('Metered API Error: '.$response->status()); + } + + $json = $response->json(); + + return IceResponseValidator::validate($json, 'Metered'); + }); + + return IceCandidatesDto::fromArray($data); + } +} diff --git a/app/Services/IceCandidate/Validators/IceResponseValidator.php b/app/Services/IceCandidate/Validators/IceResponseValidator.php new file mode 100644 index 0000000..a88b536 --- /dev/null +++ b/app/Services/IceCandidate/Validators/IceResponseValidator.php @@ -0,0 +1,68 @@ + + * + * @throws RuntimeException + */ + public static function validate(mixed $data, string $providerName = 'ICE Provider'): array + { + if (! is_array($data)) { + throw new RuntimeException("{$providerName} API returned an invalid non-array response."); + } + + // Support wrapped responses like ['iceServers' => [...]] or ['v' => [...]] + $servers = isset($data['iceServers']) && is_array($data['iceServers']) + ? $data['iceServers'] + : (isset($data['v']) && is_array($data['v']) ? $data['v'] : $data); + + if (empty($servers) || ! is_array($servers)) { + throw new RuntimeException("{$providerName} API returned an empty or invalid list of ICE servers."); + } + + $validServers = []; + $validProtocols = ['stun:', 'stuns:', 'turn:', 'turns:']; + + foreach ($servers as $index => $server) { + if (! is_array($server)) { + throw new RuntimeException("{$providerName} server entry at index [{$index}] must be an object/array."); + } + + $rawUrls = $server['urls'] ?? $server['url'] ?? null; + if (empty($rawUrls)) { + throw new RuntimeException("{$providerName} server entry at index [{$index}] is missing 'urls'."); + } + + $urlList = is_array($rawUrls) ? $rawUrls : [$rawUrls]; + foreach ($urlList as $url) { + if (! is_string($url) || trim($url) === '') { + throw new RuntimeException("{$providerName} URL at index [{$index}] must be a non-empty string."); + } + + $hasValidProtocol = false; + foreach ($validProtocols as $protocol) { + if (str_starts_with(strtolower(trim($url)), $protocol)) { + $hasValidProtocol = true; + break; + } + } + + if (! $hasValidProtocol) { + throw new RuntimeException("{$providerName} invalid ICE URL scheme [{$url}] at index [{$index}]."); + } + } + + $validServers[] = $server; + } + + return $validServers; + } +} diff --git a/bootstrap/providers.php b/bootstrap/providers.php index fc94ae6..0f75470 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -1,7 +1,9 @@ [ 'client_id' => env('MICROSOFT_CLIENT_ID', env('MICROSOFT_GRAPH_CLIENT_ID')), 'client_secret' => env('MICROSOFT_CLIENT_SECRET', env('MICROSOFT_GRAPH_CLIENT_SECRET')), - 'redirect' => env('MICROSOFT_REDIRECT_URI', env('APP_URL', 'http://localhost:8000') . '/auth/microsoft/callback'), + 'redirect' => env('MICROSOFT_REDIRECT_URI', env('APP_URL', 'http://localhost:8000').'/auth/microsoft/callback'), 'tenant' => env('MICROSOFT_TENANT_ID', env('MICROSOFT_GRAPH_TENANT_ID', 'common')), ], @@ -47,4 +47,32 @@ 'key' => env('METERED_KEY'), ], + 'convexsol' => [ + 'username' => env('CONVEXSOL_USERNAME', env('CONVEXSOL_TURN_USERNAME')), + 'password' => env('CONVEXSOL_PASSWORD', env('CONVEXSOL_TURN_PASSWORD')), + 'turn_urls' => env('CONVEXSOL_TURN_URLS', 'turn:turn.convexsol.com:3478,turns:turn.convexsol.com:5349'), + 'stun_urls' => env('CONVEXSOL_STUN_URLS', 'stun:stun.convexsol.com:3478,stun:stun.l.google.com:19302'), + ], + + 'ice' => [ + 'default' => env('ICE_CANDIDATE_PROVIDER', env('ICE_PROVIDER', 'metered')), + 'fallback' => env('ICE_FALLBACK_PROVIDER', 'convexsol'), + 'providers' => [ + 'metered' => [ + 'url' => env('METERED_URL'), + 'key' => env('METERED_KEY'), + 'timeout' => (int) env('METERED_TIMEOUT', 5), + 'cache_ttl' => (int) env('METERED_CACHE_TTL', 600), + ], + 'convexsol' => [ + 'username' => env('CONVEXSOL_USERNAME', env('CONVEXSOL_TURN_USERNAME')), + 'password' => env('CONVEXSOL_PASSWORD', env('CONVEXSOL_TURN_PASSWORD')), + 'turn_urls' => env('CONVEXSOL_TURN_URLS', 'turn:turn.convexsol.com:3478,turns:turn.convexsol.com:5349'), + 'stun_urls' => env('CONVEXSOL_STUN_URLS', 'stun:stun.convexsol.com:3478,stun:stun.l.google.com:19302'), + 'timeout' => (int) env('CONVEXSOL_TIMEOUT', 5), + 'cache_ttl' => (int) env('CONVEXSOL_CACHE_TTL', 600), + ], + ], + ], + ]; diff --git a/resources/views/components/interview/schedule-drawer.blade.php b/resources/views/components/interview/schedule-drawer.blade.php index 77489a1..dafa7b4 100644 --- a/resources/views/components/interview/schedule-drawer.blade.php +++ b/resources/views/components/interview/schedule-drawer.blade.php @@ -39,8 +39,8 @@
- Start Time (Timeline) - + Start Time (Timeline) +
diff --git a/tests/Feature/IceServerControllerTest.php b/tests/Feature/IceServerControllerTest.php new file mode 100644 index 0000000..69a3150 --- /dev/null +++ b/tests/Feature/IceServerControllerTest.php @@ -0,0 +1,117 @@ +app->forgetInstance(IceCandidateManager::class); + } + + public function test_unauthenticated_request_is_denied(): void + { + $response = $this->getJson(route('ice-servers')); + + $response->assertStatus(401) + ->assertJson(['error' => 'Unauthenticated access.']); + } + + public function test_authenticated_user_can_fetch_ice_servers(): void + { + $user = User::create([ + 'name' => 'John Interviewer', + 'email' => 'john@company.com', + 'role' => 'user', + ]); + + Http::fake([ + '*metered*' => Http::response([ + ['urls' => 'stun:stun.relay.metered.ca:80'], + [ + 'urls' => 'turn:standard.relay.metered.ca:80', + 'username' => 'metered_usr', + 'credential' => 'metered_pwd', + ], + ], 200), + ]); + + $response = $this->actingAs($user)->getJson(route('ice-servers')); + + $response->assertStatus(200); + + $data = $response->json(); + $this->assertIsArray($data); + $this->assertCount(2, $data); + $this->assertEquals('stun:stun.relay.metered.ca:80', $data[0]['urls']); + $this->assertEquals('metered_usr', $data[1]['username']); + } + + public function test_authenticated_candidate_session_can_fetch_ice_servers(): void + { + $interview = Interview::create([ + 'candidate_name' => 'Alice Candidate', + 'candidate_email' => 'alice@gmail.com', + 'candidate_phone' => '9876543210', + 'temp_password' => 'Pass-123456', + 'expires_at' => now()->addHours(2), + 'language' => 'python', + 'status' => 'scheduled', + 'submission_unique_id' => 'alice-candidate_9876543210_1752000099', + ]); + + Http::fake([ + '*metered*' => Http::response([ + ['urls' => 'stun:stun.relay.metered.ca:80'], + ], 200), + ]); + + $response = $this->withSession(['candidate_interview_id' => $interview->id]) + ->getJson(route('ice-servers')); + + $response->assertStatus(200); + $data = $response->json(); + $this->assertCount(1, $data); + $this->assertEquals('stun:stun.relay.metered.ca:80', $data[0]['urls']); + } + + public function test_switches_to_convexsol_provider_via_configuration(): void + { + $user = User::create([ + 'name' => 'Admin User', + 'email' => 'admin@company.com', + 'role' => 'admin', + ]); + + config([ + 'services.ice.default' => 'convexsol', + 'services.ice.providers.convexsol' => [ + 'username' => 'convex_corp_user', + 'password' => 'convex_corp_pass', + 'turn_urls' => ['turn:turn.convexsol.com:3478'], + 'stun_urls' => ['stun:turn.convexsol.com:3478'], + ], + ]); + + $response = $this->actingAs($user)->getJson(route('ice-servers')); + + $response->assertStatus(200); + $data = $response->json(); + $this->assertCount(2, $data); + $this->assertEquals(['stun:turn.convexsol.com:3478'], $data[0]['urls']); + $this->assertEquals('convex_corp_user', $data[1]['username']); + $this->assertEquals('convex_corp_pass', $data[1]['credential']); + } +} diff --git a/tests/Unit/ConvexSolIceCandidateProviderTest.php b/tests/Unit/ConvexSolIceCandidateProviderTest.php new file mode 100644 index 0000000..d485d37 --- /dev/null +++ b/tests/Unit/ConvexSolIceCandidateProviderTest.php @@ -0,0 +1,52 @@ + 'convex_admin', + 'password' => 'super_secret_password', + 'turn_urls' => ['turn:turn.convexsol.com:3478', 'turns:turn.convexsol.com:5349'], + 'stun_urls' => ['stun:turn.convexsol.com:3478', 'stun:stun.l.google.com:19302'], + ]); + + $candidates = $provider->getIceCandidates(); + + $this->assertCount(2, $candidates); + $array = $candidates->toArray(); + + $this->assertEquals(['stun:turn.convexsol.com:3478', 'stun:stun.l.google.com:19302'], $array[0]['urls']); + $this->assertEquals(['turn:turn.convexsol.com:3478', 'turns:turn.convexsol.com:5349'], $array[1]['urls']); + $this->assertEquals('convex_admin', $array[1]['username']); + $this->assertEquals('super_secret_password', $array[1]['credential']); + } + + public function test_caches_coturn_credentials(): void + { + $provider = new ConvexSolIceCandidateProvider([ + 'username' => 'cached_user', + 'password' => 'cached_pass', + 'turn_urls' => 'turn:turn.convexsol.com:3478', + 'stun_urls' => 'stun:turn.convexsol.com:3478', + ]); + + $candidates1 = $provider->getIceCandidates(); + $candidates2 = $provider->getIceCandidates(); + + $this->assertCount(2, $candidates1); + $this->assertEquals($candidates1->toArray(), $candidates2->toArray()); + } +} diff --git a/tests/Unit/IceCandidateDtoTest.php b/tests/Unit/IceCandidateDtoTest.php new file mode 100644 index 0000000..b56be48 --- /dev/null +++ b/tests/Unit/IceCandidateDtoTest.php @@ -0,0 +1,70 @@ +toArray(); + $this->assertEquals(['urls' => 'stun:stun.l.google.com:19302'], $array); + $this->assertArrayNotHasKey('username', $array); + $this->assertArrayNotHasKey('credential', $array); + $this->assertEquals($array, $dto->jsonSerialize()); + } + + public function test_ice_candidate_dto_with_turn_credentials(): void + { + $dto = IceCandidateDto::fromArray([ + 'urls' => ['turn:turn.convexsol.com:3478', 'turns:turn.convexsol.com:5349'], + 'username' => 'convex_user', + 'password' => 'secret_pass', + ]); + + $array = $dto->toArray(); + $this->assertEquals(['turn:turn.convexsol.com:3478', 'turns:turn.convexsol.com:5349'], $array['urls']); + $this->assertEquals('convex_user', $array['username']); + $this->assertEquals('secret_pass', $array['credential']); + } + + public function test_ice_candidates_dto_collection_handling(): void + { + $candidates = IceCandidatesDto::fromArray([ + ['urls' => 'stun:stun.l.google.com:19302'], + ['urls' => 'turn:turn.convexsol.com:3478', 'username' => 'user1', 'credential' => 'pass1'], + ]); + + $this->assertCount(2, $candidates); + $this->assertFalse($candidates->isEmpty()); + + $array = $candidates->toArray(); + $this->assertCount(2, $array); + $this->assertEquals('stun:stun.l.google.com:19302', $array[0]['urls']); + $this->assertEquals('user1', $array[1]['username']); + $this->assertEquals('pass1', $array[1]['credential']); + + $json = json_encode($candidates); + $this->assertStringContainsString('stun:stun.l.google.com:19302', $json); + $this->assertStringContainsString('turn:turn.convexsol.com:3478', $json); + } + + public function test_default_stun_factory(): void + { + $default = IceCandidatesDto::defaultStun(); + + $this->assertCount(1, $default); + $array = $default->toArray(); + $this->assertContains('stun:stun.l.google.com:19302', $array[0]['urls']); + $this->assertContains('stun:stun.cloudflare.com:3478', $array[0]['urls']); + } +} diff --git a/tests/Unit/IceCandidateManagerTest.php b/tests/Unit/IceCandidateManagerTest.php new file mode 100644 index 0000000..c777eb0 --- /dev/null +++ b/tests/Unit/IceCandidateManagerTest.php @@ -0,0 +1,134 @@ +app->forgetInstance(IceCandidateManager::class); + } + + public function test_manager_resolves_metered_as_default_driver(): void + { + Http::fake([ + '*metered*' => Http::response([ + ['urls' => 'stun:stun.relay.metered.ca:80'], + ], 200), + ]); + + $manager = app(IceCandidateManager::class); + $candidates = $manager->getIceCandidates(); + + $this->assertCount(1, $candidates); + $this->assertEquals('stun:stun.relay.metered.ca:80', $candidates->toArray()[0]['urls']); + } + + public function test_manager_resolves_convexsol_when_configured(): void + { + config([ + 'services.ice.default' => 'convexsol', + 'services.ice.providers.convexsol' => [ + 'username' => 'convex_usr', + 'password' => 'convex_pwd', + 'turn_urls' => ['turn:turn.convexsol.com:3478'], + 'stun_urls' => ['stun:turn.convexsol.com:3478'], + ], + ]); + + $manager = app(IceCandidateManager::class); + $candidates = $manager->getIceCandidates(); + + $this->assertCount(2, $candidates); + $array = $candidates->toArray(); + $this->assertEquals('convex_usr', $array[1]['username']); + $this->assertEquals('convex_pwd', $array[1]['credential']); + } + + public function test_manager_falls_back_and_logs_warning_when_primary_fails(): void + { + Log::shouldReceive('warning') + ->once() + ->withArgs(function ($message, $context) { + return str_contains($message, 'Primary ICE candidate provider [metered] failed') && + $context['primary_driver'] === 'metered' && + $context['fallback_driver'] === 'convexsol'; + }); + + // Fail primary Metered HTTP request + Http::fake([ + '*metered*' => Http::response('Gateway Error', 502), + ]); + + config([ + 'services.ice.default' => 'metered', + 'services.ice.fallback' => 'convexsol', + 'services.ice.providers.convexsol' => [ + 'username' => 'fallback_user', + 'password' => 'fallback_password', + 'turn_urls' => ['turn:turn.convexsol.com:3478'], + 'stun_urls' => ['stun:turn.convexsol.com:3478'], + ], + ]); + + $manager = app(IceCandidateManager::class); + $candidates = $manager->getIceCandidates(); + + $this->assertCount(2, $candidates); + $array = $candidates->toArray(); + $this->assertEquals('fallback_user', $array[1]['username']); + } + + public function test_manager_falls_back_to_default_stun_and_logs_error_when_both_fail(): void + { + Log::shouldReceive('warning')->once(); + Log::shouldReceive('error') + ->once() + ->withArgs(function ($message) { + return str_contains($message, 'Fallback ICE candidate provider [convexsol] also failed'); + }); + + // Fail primary Metered HTTP request + Http::fake([ + '*metered*' => Http::response('Gateway Error', 502), + ]); + + $manager = app(IceCandidateManager::class); + $manager->extend('convexsol', function () { + return new class implements IceCandidateProviderInterface + { + public function getIceCandidates(): IceCandidatesDto + { + throw new RuntimeException('ConvexSol Server Error'); + } + }; + }); + + config([ + 'services.ice.default' => 'metered', + 'services.ice.fallback' => 'convexsol', + ]); + + $candidates = $manager->getIceCandidates(); + + $this->assertCount(1, $candidates); + $this->assertContains('stun:stun.l.google.com:19302', $candidates->toArray()[0]['urls']); + } + + public function test_container_resolves_interface_to_manager(): void + { + $resolved = app(IceCandidateProviderInterface::class); + $this->assertInstanceOf(IceCandidateManager::class, $resolved); + } +} diff --git a/tests/Unit/IceCandidatesResourceTest.php b/tests/Unit/IceCandidatesResourceTest.php new file mode 100644 index 0000000..15ccd09 --- /dev/null +++ b/tests/Unit/IceCandidatesResourceTest.php @@ -0,0 +1,54 @@ +toArray(Request::create('/ice-servers', 'GET')); + + $this->assertEquals(['turn:turn.convexsol.com:3478'], $array['urls']); + $this->assertEquals('convex_user', $array['username']); + $this->assertEquals('password123', $array['credential']); + } + + public function test_ice_candidates_resource_formats_json_array_without_data_wrap(): void + { + $dto = new IceCandidatesDto([ + new IceCandidateDto(urls: 'stun:stun.l.google.com:19302'), + new IceCandidateDto( + urls: ['turn:turn.convexsol.com:3478'], + username: 'convex_user', + credential: 'password123' + ), + ]); + + $resource = new IceCandidatesResource($dto); + $request = Request::create('/ice-servers', 'GET'); + $response = $resource->toResponse($request); + + $this->assertEquals(200, $response->getStatusCode()); + + $data = $response->getData(true); + $this->assertIsArray($data); + $this->assertCount(2, $data); + $this->assertEquals('stun:stun.l.google.com:19302', $data[0]['urls']); + $this->assertEquals('convex_user', $data[1]['username']); + $this->assertEquals('password123', $data[1]['credential']); + } +} diff --git a/tests/Unit/IceResponseValidatorTest.php b/tests/Unit/IceResponseValidatorTest.php new file mode 100644 index 0000000..281e64d --- /dev/null +++ b/tests/Unit/IceResponseValidatorTest.php @@ -0,0 +1,85 @@ + 'stun:stun.relay.metered.ca:80', + ], + [ + 'urls' => [ + 'turn:standard.relay.metered.ca:80', + 'turns:standard.relay.metered.ca:443', + ], + 'username' => 'testuser', + 'credential' => 'testpass', + ], + ]; + + $validated = IceResponseValidator::validate($payload, 'Metered'); + + $this->assertCount(2, $validated); + $this->assertEquals('stun:stun.relay.metered.ca:80', $validated[0]['urls']); + $this->assertEquals('testuser', $validated[1]['username']); + } + + public function test_validates_wrapped_ice_servers_payload(): void + { + $payload = [ + 'iceServers' => [ + [ + 'urls' => 'stun:stun.convexsol.com:3478', + ], + ], + ]; + + $validated = IceResponseValidator::validate($payload, 'ConvexSol'); + + $this->assertCount(1, $validated); + $this->assertEquals('stun:stun.convexsol.com:3478', $validated[0]['urls']); + } + + public function test_throws_exception_for_non_array_payload(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Metered API returned an invalid non-array response.'); + + IceResponseValidator::validate('invalid-string', 'Metered'); + } + + public function test_throws_exception_for_empty_array_payload(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Metered API returned an empty or invalid list of ICE servers.'); + + IceResponseValidator::validate([], 'Metered'); + } + + public function test_throws_exception_for_missing_urls_field(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("Metered server entry at index [0] is missing 'urls'."); + + IceResponseValidator::validate([ + ['username' => 'someuser'], + ], 'Metered'); + } + + public function test_throws_exception_for_invalid_url_protocol(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Metered invalid ICE URL scheme [http://invalid.url] at index [0].'); + + IceResponseValidator::validate([ + ['urls' => 'http://invalid.url'], + ], 'Metered'); + } +} diff --git a/tests/Unit/MeteredIceCandidateProviderTest.php b/tests/Unit/MeteredIceCandidateProviderTest.php new file mode 100644 index 0000000..59a9970 --- /dev/null +++ b/tests/Unit/MeteredIceCandidateProviderTest.php @@ -0,0 +1,79 @@ + Http::response([ + [ + 'urls' => 'stun:stun.relay.metered.ca:80', + ], + [ + 'urls' => 'turn:standard.relay.metered.ca:80', + 'username' => 'metered_usr', + 'credential' => 'metered_pwd', + ], + ], 200), + ]); + + $provider = new MeteredIceCandidateProvider([ + 'url' => $mockUrl, + 'key' => $apiKey, + 'timeout' => 3, + 'cache_ttl' => 60, + ]); + + $candidates = $provider->getIceCandidates(); + + $this->assertCount(2, $candidates); + $array = $candidates->toArray(); + $this->assertEquals('stun:stun.relay.metered.ca:80', $array[0]['urls']); + $this->assertEquals('metered_usr', $array[1]['username']); + $this->assertEquals('metered_pwd', $array[1]['credential']); + + // Verify result is cached (subsequent call does not hit HTTP) + Http::fake([ + $mockUrl.'*' => Http::response([], 500), + ]); + + $cachedCandidates = $provider->getIceCandidates(); + $this->assertCount(2, $cachedCandidates); + } + + public function test_throws_exception_on_metered_http_error(): void + { + $mockUrl = 'https://mock.metered.live/api/v1/turn/credentials'; + + Http::fake([ + $mockUrl.'*' => Http::response('Server Error', 500), + ]); + + $provider = new MeteredIceCandidateProvider([ + 'url' => $mockUrl, + 'key' => 'key', + ]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Metered API Error: 500'); + + $provider->getIceCandidates(); + } +}