* Integrations (enable switch + allowed origins); there is no integration * permission and no SOFTPHONE_INTEGRATION_* environment dependency. */ class SoftphoneIntegrationTest extends TestCase { public function test_session_request_returns_503_when_company_integration_is_disabled() { $companyId = $this->createCompany(); $user = $this->createUser($companyId); $this->configureIntegration($companyId, false, ['https://app.customer.com']); $response = $this->postSession($user, 'https://app.customer.com'); $response->assertStatus(503); $this->assertSame('integration_disabled', $response->json('error.code')); } public function test_session_request_succeeds_for_enabled_company_and_allowed_origin() { $companyId = $this->createCompany(); $user = $this->createUser($companyId); $this->configureIntegration($companyId, true, [ 'https://app.customer.com', 'https://crm.customer.com', ]); $response = $this->postSession($user, 'https://crm.customer.com'); $response->assertStatus(201); $response->assertJson(['success' => true]); $this->assertSame(route('softphone.integration'), $response->json('iframe_url')); $response->assertHeader('Access-Control-Allow-Origin', 'https://crm.customer.com'); $response->assertHeader('Access-Control-Allow-Credentials', 'true'); } public function test_session_request_rejects_origin_outside_company_allow_list() { $companyId = $this->createCompany(); $user = $this->createUser($companyId); $this->configureIntegration($companyId, true, ['https://app.customer.com']); $response = $this->postSession($user, 'https://evil.example.com'); $response->assertStatus(403); $this->assertSame('origin_not_allowed', $response->json('error.code')); $response->assertHeaderMissing('Access-Control-Allow-Origin'); } public function test_integration_does_not_require_any_user_permission() { $companyId = $this->createCompany(); $user = $this->createUser($companyId); $this->configureIntegration($companyId, true, ['https://app.customer.com']); $this->assertCount(0, $user->getAllPermissions()); $response = $this->postSession($user, 'https://app.customer.com'); $response->assertStatus(201); $response->assertJson(['success' => true]); } public function test_integration_settings_are_isolated_per_company() { $companyA = $this->createCompany(); $companyB = $this->createCompany(); $userA = $this->createUser($companyA); $userB = $this->createUser($companyB); $this->configureIntegration($companyA, true, ['https://app.customer.com']); $this->configureIntegration($companyB, true, ['https://other.customer.com']); $this->postSession($userA, 'https://app.customer.com')->assertStatus(201); // The same origin is not authorized for the second company. $this->postSession($userB, 'https://app.customer.com')->assertStatus(403); $this->postSession($userB, 'https://other.customer.com')->assertStatus(201); // Disabling one company does not disable the other. $this->configureIntegration($companyA, false, ['https://app.customer.com']); $this->postSession($userA, 'https://app.customer.com')->assertStatus(503); $this->postSession($userB, 'https://other.customer.com')->assertStatus(201); } public function test_revoke_session_endpoint_follows_company_switch() { $companyId = $this->createCompany(); $user = $this->createUser($companyId); $this->configureIntegration($companyId, false, ['https://app.customer.com']); $this->postRevoke($user, 'https://app.customer.com')->assertStatus(503); $this->configureIntegration($companyId, true, ['https://app.customer.com']); $this->postRevoke($user, 'https://app.customer.com') ->assertStatus(200) ->assertJson(['success' => true]); } public function test_embed_page_returns_503_when_integration_is_disabled() { $companyId = $this->createCompany(); $user = $this->createUser($companyId); $this->configureIntegration($companyId, false, ['https://app.customer.com']); $response = $this->actingAs($user)->get('/softphone/integration'); $response->assertStatus(503); } public function test_embed_page_csp_frame_ancestors_uses_company_origins() { $companyId = $this->createCompany(); $user = $this->createUser($companyId); $this->configureIntegration($companyId, true, [ 'https://app.customer.com', 'https://crm.customer.com', ]); $this->mock(SoftphoneConfigResolver::class, function ($mock) { $mock->shouldReceive('resolveForUser')->once()->andReturn($this->phoneConfigPayload()); }); $response = $this->actingAs($user)->get('/softphone/integration'); $response->assertStatus(200); $response->assertHeader( 'Content-Security-Policy', "frame-ancestors 'self' https://app.customer.com https://crm.customer.com" ); $response->assertHeaderMissing('X-Frame-Options'); } public function test_cors_preflight_is_answered_without_authentication() { $response = $this->call('OPTIONS', '/api/softphone/session', [], [], [], [ 'HTTP_ORIGIN' => 'https://app.customer.com', 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'POST', ]); $response->assertStatus(204); $response->assertHeader('Access-Control-Allow-Origin', 'https://app.customer.com'); $response->assertHeader('Access-Control-Allow-Methods', 'POST, OPTIONS'); } public function test_company_settings_page_persists_integration_configuration() { $companyId = $this->createCompany(); $user = $this->createUser($companyId); Permission::findOrCreate('admin.settings'); $user->givePermissionTo('admin.settings'); $response = $this->actingAs($user)->post('/settings/company_settings', [ 'company_name' => 'Integration Settings Co', 'softphoneIntegrationEnabled' => '1', 'softphoneIntegrationOrigins' => "https://app.customer.com,\nhttps://crm.customer.com/", ]); $response->assertRedirect(route('settings.company_settings')); $settings = app(SoftphoneIntegrationSettings::class); $this->assertTrue($settings->isEnabled($companyId)); $this->assertSame( ['https://app.customer.com', 'https://crm.customer.com'], $settings->origins($companyId) ); } public function test_obsolete_environment_configuration_is_gone() { $this->assertNull(config('softphone_integration')); $this->assertFileDoesNotExist(config_path('softphone_integration.php')); } public function test_origin_normalization_rejects_wildcards_and_paths() { $this->assertSame( "https://app.customer.com\nhttps://crm.customer.com:8443", SettingsHelper::normalizeSoftphoneIntegrationOrigins( " https://APP.customer.com/ , https://crm.customer.com:8443\nhttps://app.customer.com " ) ); $this->assertFalse(SettingsHelper::hasOnlyValidSoftphoneIntegrationOrigins('*')); $this->assertFalse(SettingsHelper::hasOnlyValidSoftphoneIntegrationOrigins('https://app.customer.com/login')); $this->assertFalse(SettingsHelper::hasOnlyValidSoftphoneIntegrationOrigins('app.customer.com')); $this->assertTrue(SettingsHelper::hasOnlyValidSoftphoneIntegrationOrigins( "https://app.customer.com\nhttps://crm.customer.com" )); } private function postSession(User $user, ?string $origin) { $this->resetAuthGuards(); $headers = ['Authorization' => 'Bearer ' . $user->jwt_token]; if ($origin !== null) { $headers['Origin'] = $origin; } return $this->postJson('/api/softphone/session', [], $headers); } private function postRevoke(User $user, ?string $origin) { $this->resetAuthGuards(); $headers = ['Authorization' => 'Bearer ' . $user->jwt_token]; if ($origin !== null) { $headers['Origin'] = $origin; } return $this->postJson('/api/softphone/session/revoke', [], $headers); } /** * The test client reuses the container between requests of the same test, * so guards and the JWT singleton would otherwise retain the previous * request's authenticated user/token. */ private function resetAuthGuards(): void { $this->app['auth']->forgetGuards(); $this->app['tymon.jwt']->unsetToken(); $this->app['tymon.jwt.auth']->unsetToken(); } private function configureIntegration(int $companyId, bool $enabled, array $origins): void { CompanySettings::set( SettingsHelper::S_COMPANY_SOFTPHONE_INTEGRATION_ENABLED, $enabled ? 1 : 0, $companyId ); CompanySettings::set( SettingsHelper::S_COMPANY_SOFTPHONE_INTEGRATION_ORIGINS, implode("\n", $origins), $companyId ); } private function createCompany(): int { return DB::table('bap_companies')->insertGetId([ 'name' => 'Softphone Integration Co ' . uniqid(), 'is_enabled' => 1, 'created_at' => now(), 'updated_at' => now(), ]); } private function createUser(int $companyId): User { $payload = [ 'name' => 'Softphone Integration User', 'email' => 'softphone-integration-' . uniqid() . '@example.com', 'password' => bcrypt('secret'), 'company_id' => $companyId, 'created_at' => now(), 'updated_at' => now(), ]; if (Schema::hasColumn('users', 'is_active')) { $payload['is_active'] = 1; } $userId = DB::table('users')->insertGetId($payload); $user = User::query()->findOrFail($userId); $token = \JWTAuth::fromUser($user); if (Schema::hasColumn('users', 'jwt_token')) { DB::table('users')->where('id', $user->id)->update(['jwt_token' => $token]); } $user->jwt_token = $token; return $user; } private function phoneConfigPayload(): array { return [ 'user' => [ 'name' => 'Softphone Integration User', 'id' => 1, 'email' => 'softphone-integration@example.com', 'timezone' => 'UTC', 'company_name' => 'Softphone Integration Co', ], 'description' => 'Test phone', 'extension' => '991', 'password' => 'secret', 'pbx_url' => 'pbx.example.com', 'port' => 8089, 'path' => '/ws', 'getCallerInfoUrl' => '/dashboard/getCallerInfo', 'autoHoldEnabled' => false, 'autoAnswer' => false, 'autoAnswerTimeout' => 5, 'surveyTransferEnabled' => false, 'pauseEnablesDnd' => false, 'whatsAppCountryCode' => '20', 'dndPolicy' => 'allow', ]; } }