diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 55b59c9..43e0ee3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,13 +13,7 @@ jobs: max-parallel: 15 fail-fast: false matrix: - coverage: [ 'none' ] php-versions: [ '8.2', '8.3', '8.4', '8.5' ] - exclude: - - php-versions: '8.5' - include: - - php-versions: '8.5' - coverage: 'xdebug' name: PHP ${{ matrix.php-versions }} steps: @@ -30,8 +24,8 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-versions }} - extensions: json, mbstring, xdebug - coverage: ${{ matrix.coverage }} + extensions: json, mbstring + coverage: pcov - name: Install dependencies run: composer update --no-interaction --prefer-dist --no-suggest --prefer-stable @@ -39,9 +33,12 @@ jobs: - name: Lint composer.json run: composer validate --strict - - name: Run Tests - run: vendor/bin/phpunit -v + - name: Run Tests with Coverage + run: vendor/bin/phpunit -v --coverage-text --coverage-clover=build/logs/clover.xml - - name: Upload coverage results - if: matrix.coverage != 'none' + - name: Upload coverage to Codecov + if: matrix.php-versions == '8.5' uses: codecov/codecov-action@v5 + with: + files: build/logs/clover.xml + fail_ci_if_error: false diff --git a/tests/ConfigRelativeUriTest.php b/tests/ConfigRelativeUriTest.php new file mode 100644 index 0000000..9115622 --- /dev/null +++ b/tests/ConfigRelativeUriTest.php @@ -0,0 +1,68 @@ +urlGenerator = $urlGenerator; + + // Set up a minimal Facade application container + $app = new \ArrayObject(); + $app['url'] = $urlGenerator; + + Facade::setFacadeApplication($app); + } + + protected function tearDown(): void + { + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + parent::tearDown(); + } + + /** @var \Mockery\MockInterface */ + protected $urlGenerator; + + /** + * @test + */ + public function it_resolves_relative_uri_using_url_to(): void + { + $this->urlGenerator + ->shouldReceive('to') + ->with('/callback/oauth') + ->once() + ->andReturn('http://localhost/callback/oauth'); + + $config = new Config('key', 'secret', '/callback/oauth'); + $result = $config->get(); + + $this->assertSame('http://localhost/callback/oauth', $result['redirect']); + } + + /** + * @test + */ + public function it_does_not_resolve_absolute_uri(): void + { + $this->urlGenerator->shouldNotReceive('to'); + + $config = new Config('key', 'secret', 'https://example.com/callback'); + $result = $config->get(); + + $this->assertSame('https://example.com/callback', $result['redirect']); + } +} diff --git a/tests/ConfigRetrieverTest.php b/tests/ConfigRetrieverTest.php index f3e54dc..235e9b3 100644 --- a/tests/ConfigRetrieverTest.php +++ b/tests/ConfigRetrieverTest.php @@ -109,6 +109,124 @@ public function it_retrieves_a_config_from_the_services_with_guzzle(): void $this->assertSame($additionalConfigItem, $result['additional']); $this->assertSame(['verify' => false], $result['guzzle']); } + + /** + * @test + */ + public function it_spoofs_config_when_running_in_console_and_config_is_empty(): void + { + \SocialiteProviders\Manager\Helpers\applicationStub::$runningInConsole = true; + + $providerName = 'test'; + self::$functions + ->shouldReceive('config') + ->with("services.{$providerName}") + ->once() + ->andReturn(null); + $configRetriever = new ConfigRetriever; + + $result = $configRetriever->fromServices($providerName)->get(); + + $this->assertStringContainsString('_KEY', $result['client_id']); + $this->assertStringContainsString('_SECRET', $result['client_secret']); + $this->assertStringContainsString('_REDIRECT_URI', $result['redirect']); + + \SocialiteProviders\Manager\Helpers\applicationStub::$runningInConsole = false; + } + + /** + * @test + */ + public function it_returns_empty_array_for_missing_guzzle_config(): void + { + $providerName = 'test'; + $config = [ + 'client_id' => 'key', + 'client_secret' => 'secret', + 'redirect' => 'uri', + ]; + self::$functions + ->shouldReceive('config') + ->with("services.{$providerName}") + ->once() + ->andReturn($config); + $configRetriever = new ConfigRetriever; + + $result = $configRetriever->fromServices($providerName)->get(); + + $this->assertSame([], $result['guzzle']); + } + + /** + * @test + */ + public function it_returns_null_for_missing_additional_config_key(): void + { + $providerName = 'test'; + $config = [ + 'client_id' => 'key', + 'client_secret' => 'secret', + 'redirect' => 'uri', + ]; + self::$functions + ->shouldReceive('config') + ->with("services.{$providerName}") + ->once() + ->andReturn($config); + $configRetriever = new ConfigRetriever; + + $result = $configRetriever->fromServices($providerName, ['tenant_id'])->get(); + + $this->assertNull($result['tenant_id']); + } + + /** + * @test + */ + public function it_throws_for_missing_required_key(): void + { + $this->expectExceptionObject(new MissingConfigException('Missing services entry for test.client_secret')); + + $providerName = 'test'; + $config = [ + 'client_id' => 'key', + // client_secret is missing + 'redirect' => 'uri', + ]; + self::$functions + ->shouldReceive('config') + ->with("services.{$providerName}") + ->once() + ->andReturn($config); + $configRetriever = new ConfigRetriever; + + $configRetriever->fromServices($providerName)->get(); + } + + /** + * @test + */ + public function it_deduplicates_guzzle_in_additional_config_keys(): void + { + $providerName = 'test'; + $config = [ + 'client_id' => 'key', + 'client_secret' => 'secret', + 'redirect' => 'uri', + 'guzzle' => ['timeout' => 10], + ]; + self::$functions + ->shouldReceive('config') + ->with("services.{$providerName}") + ->once() + ->andReturn($config); + $configRetriever = new ConfigRetriever; + + // Pass 'guzzle' explicitly — it should be deduplicated, not appear twice + $result = $configRetriever->fromServices($providerName, ['guzzle'])->get(); + + $this->assertSame(['timeout' => 10], $result['guzzle']); + } } namespace SocialiteProviders\Manager\Helpers; @@ -132,8 +250,10 @@ function app() class applicationStub { + public static bool $runningInConsole = false; + public function runningInConsole(): bool { - return false; + return self::$runningInConsole; } } diff --git a/tests/ConfigTraitTest.php b/tests/ConfigTraitTest.php new file mode 100644 index 0000000..4d629d3 --- /dev/null +++ b/tests/ConfigTraitTest.php @@ -0,0 +1,217 @@ +getConfig($key, $default); + } +} + +class ConfigTraitTest extends TestCase +{ + protected function makeConfigUser(array $configArray = []): ConfigTraitUser + { + $user = new ConfigTraitUser; + $config = new Config( + $configArray['client_id'] ?? 'id', + $configArray['client_secret'] ?? 'secret', + $configArray['redirect'] ?? 'redirect', + array_diff_key($configArray, array_flip(['client_id', 'client_secret', 'redirect'])) + ); + $user->setConfig($config); + + return $user; + } + + /** + * @test + */ + public function setConfig_sets_config_array_and_returns_self(): void + { + $user = new ConfigTraitUser; + $config = new Config('id', 'secret', 'redirect'); + $result = $user->setConfig($config); + + $this->assertSame($user, $result); + } + + /** + * @test + */ + public function setConfig_populates_clientId_clientSecret_redirectUrl(): void + { + $user = new ConfigTraitUser; + $config = new Config('my_id', 'my_secret', 'my_redirect'); + $user->setConfig($config); + + $this->assertSame('my_id', $user->clientId); + $this->assertSame('my_secret', $user->clientSecret); + $this->assertSame('my_redirect', $user->redirectUrl); + } + + /** + * @test + */ + public function additionalConfigKeys_returns_empty_array_by_default(): void + { + $this->assertSame([], ConfigTraitUser::additionalConfigKeys()); + } + + /** + * @test + */ + public function getConfig_returns_full_config_when_no_key(): void + { + $user = $this->makeConfigUser([ + 'client_id' => 'id', + 'client_secret' => 'secret', + 'redirect' => 'redirect', + ]); + + $result = $user->callGetConfig(); + + $this->assertIsArray($result); + $this->assertSame('id', $result['client_id']); + $this->assertSame('secret', $result['client_secret']); + $this->assertSame('redirect', $result['redirect']); + } + + /** + * @test + */ + public function getConfig_returns_value_for_existing_key(): void + { + $user = $this->makeConfigUser([ + 'client_id' => 'id', + 'client_secret' => 'secret', + 'redirect' => 'redirect', + 'tenant' => 'my_tenant', + ]); + + $this->assertSame('my_tenant', $user->callGetConfig('tenant')); + } + + /** + * @test + */ + public function getConfig_returns_default_for_missing_key(): void + { + $user = $this->makeConfigUser(); + + $this->assertSame('fallback', $user->callGetConfig('nonexistent', 'fallback')); + } + + /** + * @test + */ + public function getConfig_returns_default_for_empty_string_value(): void + { + $user = $this->makeConfigUser([ + 'client_id' => 'id', + 'client_secret' => 'secret', + 'redirect' => 'redirect', + 'empty_val' => '', + ]); + + // Current behavior: empty() treats '' as empty, so default is returned + $this->assertSame('default', $user->callGetConfig('empty_val', 'default')); + } + + /** + * @test + */ + public function getConfig_returns_default_for_zero_value(): void + { + $user = $this->makeConfigUser([ + 'client_id' => 'id', + 'client_secret' => 'secret', + 'redirect' => 'redirect', + 'zero_val' => 0, + ]); + + // Current behavior: empty() treats 0 as empty, so default is returned + $this->assertSame('default', $user->callGetConfig('zero_val', 'default')); + } + + /** + * @test + */ + public function getConfig_returns_default_for_false_value(): void + { + $user = $this->makeConfigUser([ + 'client_id' => 'id', + 'client_secret' => 'secret', + 'redirect' => 'redirect', + 'false_val' => false, + ]); + + // Current behavior: empty() treats false as empty, so default is returned + $this->assertSame('default', $user->callGetConfig('false_val', 'default')); + } + + /** + * @test + */ + public function getConfig_returns_null_as_default_when_no_default_specified(): void + { + $user = $this->makeConfigUser(); + + $this->assertNull($user->callGetConfig('nonexistent')); + } + + /** + * @test + */ + public function getConfig_returns_full_config_when_key_is_null(): void + { + $user = $this->makeConfigUser(); + $result = $user->callGetConfig(null); + + $this->assertIsArray($result); + $this->assertArrayHasKey('client_id', $result); + } + + /** + * @test + */ + public function getConfig_returns_truthy_values_correctly(): void + { + $user = $this->makeConfigUser([ + 'client_id' => 'id', + 'client_secret' => 'secret', + 'redirect' => 'redirect', + 'count' => 42, + ]); + + $this->assertSame(42, $user->callGetConfig('count')); + } + + /** + * @test + */ + public function getConfig_returns_array_values_correctly(): void + { + $guzzle = ['verify' => false, 'timeout' => 30]; + $user = $this->makeConfigUser([ + 'client_id' => 'id', + 'client_secret' => 'secret', + 'redirect' => 'redirect', + 'guzzle' => $guzzle, + ]); + + $this->assertSame($guzzle, $user->callGetConfig('guzzle')); + } +} diff --git a/tests/OAuth1AbstractProviderTest.php b/tests/OAuth1AbstractProviderTest.php new file mode 100644 index 0000000..8cd1f38 --- /dev/null +++ b/tests/OAuth1AbstractProviderTest.php @@ -0,0 +1,355 @@ +id = $user['id'] ?? null; + $mapped->nickname = $user['nickname'] ?? null; + + return $mapped; + } + + public static function additionalConfigKeys(): array + { + return []; + } +} + +class OAuth1AbstractProviderTest extends TestCase +{ + use \Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration; + + protected function makeServerStub(): OAuth1ServerStub + { + return new OAuth1ServerStub([ + 'identifier' => 'client_id', + 'secret' => 'client_secret', + 'callback_uri' => 'http://localhost/callback', + ]); + } + + protected function makeProvider(?Request $request = null, ?OAuth1ServerStub $server = null): TestableOAuth1Provider + { + return new TestableOAuth1Provider( + $request ?? Request::create('foo'), + $server ?? $this->makeServerStub() + ); + } + + protected function makeSessionRequest(array $params, TemporaryCredentials $tempCreds): Request + { + $request = Request::create('foo', 'GET', $params); + $session = m::mock(\Illuminate\Contracts\Session\Session::class); + $session->shouldReceive('get') + ->with('oauth_temp') + ->andReturn(serialize($tempCreds)); + $request->setLaravelSession($session); + + return $request; + } + + /** + * @test + */ + public function serviceContainerKey_returns_prefixed_name(): void + { + $result = TestableOAuth1Provider::serviceContainerKey('twitter'); + + $this->assertSame('SocialiteProviders.config.twitter', $result); + } + + /** + * @test + */ + public function stateless_returns_self_for_fluent_chaining(): void + { + $provider = $this->makeProvider(); + + $this->assertSame($provider, $provider->stateless()); + } + + /** + * @test + */ + public function stateless_sets_stateless_property(): void + { + $provider = $this->makeProvider(); + $provider->stateless(false); + + $reflection = new \ReflectionClass($provider); + $prop = $reflection->getProperty('stateless'); + $prop->setAccessible(true); + + $this->assertFalse($prop->getValue($provider)); + } + + /** + * @test + */ + public function stateless_defaults_to_true_argument(): void + { + $provider = $this->makeProvider(); + $provider->stateless(false); + $provider->stateless(); + + $reflection = new \ReflectionClass($provider); + $prop = $reflection->getProperty('stateless'); + $prop->setAccessible(true); + + $this->assertTrue($prop->getValue($provider)); + } + + /** + * @test + */ + public function scopes_delegates_to_server_and_returns_self(): void + { + $server = $this->makeServerStub(); + $provider = $this->makeProvider(null, $server); + + $result = $provider->scopes(['read', 'write']); + + $this->assertSame($provider, $result); + } + + /** + * @test + */ + public function with_delegates_to_server_and_returns_self(): void + { + $server = $this->makeServerStub(); + $provider = $this->makeProvider(null, $server); + + $result = $provider->with(['foo' => 'bar']); + + $this->assertSame($provider, $result); + } + + /** + * @test + */ + public function setConfig_delegates_to_server_and_returns_self(): void + { + $server = $this->makeServerStub(); + $provider = $this->makeProvider(null, $server); + + $config = new Config('id', 'secret', 'redirect'); + $result = @$provider->setConfig($config); // suppress dynamic property deprecation + + $this->assertSame($provider, $result); + } + + /** + * @test + */ + public function user_throws_when_verifier_is_missing(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Missing OAuth verifier'); + + $request = Request::create('foo', 'GET', []); + $provider = $this->makeProvider($request); + + $provider->user(); + } + + /** + * @test + */ + public function user_returns_user_with_token_and_access_token_response_body(): void + { + $server = m::mock(OAuth1ServerStub::class)->makePartial(); + + $tempCreds = new TemporaryCredentials; + $tempCreds->setIdentifier('temp_tok'); + $tempCreds->setSecret('temp_sec'); + + $tokenCreds = new TokenCredentials; + $tokenCreds->setIdentifier('access_tok'); + $tokenCreds->setSecret('access_sec'); + + $server->shouldReceive('getTokenCredentials') + ->once() + ->andReturn([ + 'tokenCredentials' => $tokenCreds, + 'credentialsResponseBody' => 'oauth_token=access_tok&oauth_token_secret=access_sec&user_id=123', + ]); + + $userDetails = new User; + $userDetails->id = '123'; + $userDetails->nickname = 'test_user'; + $server->shouldReceive('getUserDetails') + ->once() + ->with($tokenCreds) + ->andReturn($userDetails); + + $request = $this->makeSessionRequest( + ['oauth_token' => 'temp_tok', 'oauth_verifier' => 'verifier_code'], + $tempCreds + ); + + $provider = new TestableOAuth1Provider($request, $server); + $user = $provider->user(); + + $this->assertInstanceOf(User::class, $user); + $this->assertSame('access_tok', $user->token); + $this->assertSame('access_sec', $user->tokenSecret); + $this->assertIsArray($user->accessTokenResponseBody); + $this->assertSame('123', $user->accessTokenResponseBody['user_id']); + } + + /** + * @test + */ + public function user_throws_credentials_exception_on_unparseable_response_body(): void + { + $this->expectException(CredentialsException::class); + $this->expectExceptionMessage('Unable to parse token credentials response'); + + $server = m::mock(OAuth1ServerStub::class)->makePartial(); + + $tokenCreds = new TokenCredentials; + $tokenCreds->setIdentifier('tok'); + $tokenCreds->setSecret('sec'); + + $server->shouldReceive('getTokenCredentials') + ->andReturn([ + 'tokenCredentials' => $tokenCreds, + 'credentialsResponseBody' => '', + ]); + + $userDetails = new User; + $server->shouldReceive('getUserDetails')->andReturn($userDetails); + + $tempCreds = new TemporaryCredentials; + $tempCreds->setIdentifier('temp'); + $tempCreds->setSecret('sec'); + + $request = $this->makeSessionRequest( + ['oauth_token' => 'temp', 'oauth_verifier' => 'ver'], + $tempCreds + ); + + $provider = new TestableOAuth1Provider($request, $server); + $provider->user(); + } + + /** + * @test + */ + public function userFromTokenAndSecret_returns_user_with_credentials(): void + { + $server = m::mock(OAuth1ServerStub::class)->makePartial(); + + $userDetails = new User; + $userDetails->id = '456'; + $server->shouldReceive('getUserDetails') + ->once() + ->with(m::on(function ($creds) { + return $creds instanceof TokenCredentials + && $creds->getIdentifier() === 'my_token' + && $creds->getSecret() === 'my_secret'; + })) + ->andReturn($userDetails); + + $provider = new TestableOAuth1Provider(Request::create('foo'), $server); + $user = $provider->userFromTokenAndSecret('my_token', 'my_secret'); + + $this->assertSame('my_token', $user->token); + $this->assertSame('my_secret', $user->tokenSecret); + } + + /** + * @test + */ + public function redirect_stores_serialized_temp_and_returns_redirect(): void + { + $server = m::mock(OAuth1ServerStub::class)->makePartial(); + + $tempCreds = new TemporaryCredentials; + $tempCreds->setIdentifier('temp_id'); + $tempCreds->setSecret('temp_secret'); + + $server->shouldReceive('getTemporaryCredentials') + ->once() + ->andReturn($tempCreds); + $server->shouldReceive('getAuthorizationUrl') + ->once() + ->with($tempCreds) + ->andReturn('http://auth.example.com?oauth_token=temp_id'); + + $request = Request::create('foo'); + $session = m::mock(\Illuminate\Contracts\Session\Session::class); + $session->shouldReceive('put') + ->once() + ->with('oauth_temp', serialize($tempCreds)); + $request->setLaravelSession($session); + + $provider = new TestableOAuth1Provider($request, $server); + + $response = $provider->redirect(); + + $this->assertInstanceOf(RedirectResponse::class, $response); + $this->assertSame('http://auth.example.com?oauth_token=temp_id', $response->getTargetUrl()); + } + + /** + * @test + */ + public function getToken_deserializes_temp_credentials_from_session(): void + { + $server = m::mock(OAuth1ServerStub::class)->makePartial(); + + $tempCreds = new TemporaryCredentials; + $tempCreds->setIdentifier('temp_tok'); + $tempCreds->setSecret('temp_sec'); + + $tokenCreds = new TokenCredentials; + $tokenCreds->setIdentifier('tok'); + $tokenCreds->setSecret('sec'); + + $server->shouldReceive('getTokenCredentials') + ->once() + ->with( + m::on(fn ($c) => $c instanceof TemporaryCredentials && $c->getIdentifier() === 'temp_tok'), + 'temp_tok', + 'ver' + ) + ->andReturn([ + 'tokenCredentials' => $tokenCreds, + 'credentialsResponseBody' => 'oauth_token=tok&oauth_token_secret=sec', + ]); + + $userDetails = new User; + $server->shouldReceive('getUserDetails')->andReturn($userDetails); + + $request = $this->makeSessionRequest( + ['oauth_token' => 'temp_tok', 'oauth_verifier' => 'ver'], + $tempCreds + ); + + $provider = new TestableOAuth1Provider($request, $server); + + $user = $provider->user(); + $this->assertSame('tok', $user->token); + } +} diff --git a/tests/OAuth1ServerTest.php b/tests/OAuth1ServerTest.php new file mode 100644 index 0000000..78385c8 --- /dev/null +++ b/tests/OAuth1ServerTest.php @@ -0,0 +1,433 @@ + 'client_id_test', + 'secret' => 'client_secret_test', + 'callback_uri' => 'http://localhost/callback', + ]); + } + + protected function makeTemporaryCredentials(string $identifier = 'temp_id', string $secret = 'temp_secret'): TemporaryCredentials + { + $credentials = new TemporaryCredentials(); + $credentials->setIdentifier($identifier); + $credentials->setSecret($secret); + + return $credentials; + } + + protected function makeSuccessResponse(string $body = 'oauth_token=tok123&oauth_token_secret=secret456'): Response + { + return new Response(200, [], $body); + } + + /** + * @test + */ + public function getTokenCredentials_returns_array_with_credentials_and_body(): void + { + $server = $this->makeServer(); + $server->http = m::mock(Client::class); + $server->http + ->shouldReceive('post') + ->once() + ->with('test', m::on(function ($options) { + return isset($options['headers']) + && isset($options['form_params']['oauth_verifier']) + && $options['form_params']['oauth_verifier'] === 'test_verifier'; + })) + ->andReturn($this->makeSuccessResponse()); + + $tempCreds = $this->makeTemporaryCredentials(); + $result = $server->getTokenCredentials($tempCreds, 'temp_id', 'test_verifier'); + + $this->assertIsArray($result); + $this->assertArrayHasKey('tokenCredentials', $result); + $this->assertArrayHasKey('credentialsResponseBody', $result); + $this->assertInstanceOf(TokenCredentials::class, $result['tokenCredentials']); + } + + /** + * @test + */ + public function getTokenCredentials_returns_parsed_token_values(): void + { + $server = $this->makeServer(); + $server->http = m::mock(Client::class); + $server->http + ->shouldReceive('post') + ->andReturn($this->makeSuccessResponse('oauth_token=my_token&oauth_token_secret=my_secret')); + + $tempCreds = $this->makeTemporaryCredentials(); + $result = $server->getTokenCredentials($tempCreds, 'temp_id', 'verifier'); + + $this->assertSame('my_token', $result['tokenCredentials']->getIdentifier()); + $this->assertSame('my_secret', $result['tokenCredentials']->getSecret()); + } + + /** + * @test + */ + public function getTokenCredentials_returns_raw_response_body(): void + { + $body = 'oauth_token=tok&oauth_token_secret=sec&extra_field=bonus'; + $server = $this->makeServer(); + $server->http = m::mock(Client::class); + $server->http + ->shouldReceive('post') + ->andReturn($this->makeSuccessResponse($body)); + + $tempCreds = $this->makeTemporaryCredentials(); + $result = $server->getTokenCredentials($tempCreds, 'temp_id', 'verifier'); + + $this->assertSame($body, (string) $result['credentialsResponseBody']); + } + + /** + * @test + */ + public function getTokenCredentials_preserves_extra_fields_in_body(): void + { + $body = 'oauth_token=tok&oauth_token_secret=sec&user_id=42&screen_name=test'; + $server = $this->makeServer(); + $server->http = m::mock(Client::class); + $server->http + ->shouldReceive('post') + ->andReturn($this->makeSuccessResponse($body)); + + $tempCreds = $this->makeTemporaryCredentials(); + $result = $server->getTokenCredentials($tempCreds, 'temp_id', 'verifier'); + + parse_str((string) $result['credentialsResponseBody'], $parsed); + $this->assertSame('42', $parsed['user_id']); + $this->assertSame('test', $parsed['screen_name']); + } + + /** + * @test + */ + public function getTokenCredentials_throws_on_mismatched_identifier(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Temporary identifier passed back by server does not match'); + + $server = $this->makeServer(); + $server->http = m::mock(Client::class); + $server->http->shouldNotReceive('post'); + + $tempCreds = $this->makeTemporaryCredentials('real_id'); + $server->getTokenCredentials($tempCreds, 'DIFFERENT_id', 'verifier'); + } + + /** + * @test + */ + public function getTokenCredentials_does_not_make_http_call_on_mismatched_identifier(): void + { + $server = $this->makeServer(); + $server->http = m::mock(Client::class); + $server->http->shouldNotReceive('post'); + + $tempCreds = $this->makeTemporaryCredentials('id_a'); + + try { + $server->getTokenCredentials($tempCreds, 'id_b', 'verifier'); + } catch (\InvalidArgumentException $e) { + // Expected — Mockery will verify shouldNotReceive in tearDown + } + } + + /** + * @test + */ + public function getTokenCredentials_throws_credentials_exception_on_bad_response(): void + { + $this->expectException(CredentialsException::class); + $this->expectExceptionMessage('401'); + + $server = $this->makeServer(); + + $badResponse = new Response(401, [], 'Unauthorized'); + $request = m::mock(RequestInterface::class); + $exception = new BadResponseException('Bad response', $request, $badResponse); + + $server->http = m::mock(Client::class); + $server->http + ->shouldReceive('post') + ->once() + ->andThrow($exception); + + $tempCreds = $this->makeTemporaryCredentials(); + $server->getTokenCredentials($tempCreds, 'temp_id', 'verifier'); + } + + /** + * @test + */ + public function getTokenCredentials_includes_status_code_in_credentials_exception(): void + { + $server = $this->makeServer(); + + $badResponse = new Response(403, [], 'Forbidden'); + $request = m::mock(RequestInterface::class); + $exception = new BadResponseException('Bad response', $request, $badResponse); + + $server->http = m::mock(Client::class); + $server->http->shouldReceive('post')->andThrow($exception); + + $tempCreds = $this->makeTemporaryCredentials(); + + try { + $server->getTokenCredentials($tempCreds, 'temp_id', 'verifier'); + $this->fail('Expected CredentialsException'); + } catch (CredentialsException $e) { + $this->assertStringContainsString('403', $e->getMessage()); + $this->assertStringContainsString('Forbidden', $e->getMessage()); + } + } + + /** + * @test + */ + public function getTokenCredentials_uses_guzzle_array_options_when_client_is_guzzle(): void + { + $server = $this->makeServer(); + $server->http = m::mock(Client::class); + $server->http + ->shouldReceive('post') + ->once() + ->with('test', m::on(function ($options) { + return is_array($options) + && array_key_exists('headers', $options) + && array_key_exists('form_params', $options); + })) + ->andReturn($this->makeSuccessResponse()); + + $tempCreds = $this->makeTemporaryCredentials(); + $server->getTokenCredentials($tempCreds, 'temp_id', 'verifier'); + } + + /** + * @test + */ + public function getTokenCredentials_passes_oauth_verifier_in_form_params(): void + { + $server = $this->makeServer(); + $server->http = m::mock(Client::class); + $server->http + ->shouldReceive('post') + ->once() + ->with('test', m::on(function ($options) { + return $options['form_params'] === ['oauth_verifier' => 'my_verifier_code']; + })) + ->andReturn($this->makeSuccessResponse()); + + $tempCreds = $this->makeTemporaryCredentials(); + $server->getTokenCredentials($tempCreds, 'temp_id', 'my_verifier_code'); + } + + /** + * @test + */ + public function getTokenCredentials_posts_to_urlTokenCredentials(): void + { + $server = $this->makeServer(); + $server->http = m::mock(Client::class); + $server->http + ->shouldReceive('post') + ->once() + ->with('test', m::any()) + ->andReturn($this->makeSuccessResponse()); + + $tempCreds = $this->makeTemporaryCredentials(); + $server->getTokenCredentials($tempCreds, 'temp_id', 'verifier'); + } + + /** + * @test + */ + public function getTokenCredentials_sends_authorization_header(): void + { + $server = $this->makeServer(); + $server->http = m::mock(Client::class); + $server->http + ->shouldReceive('post') + ->once() + ->with('test', m::on(function ($options) { + return isset($options['headers']['Authorization']) + && str_starts_with($options['headers']['Authorization'], 'OAuth '); + })) + ->andReturn($this->makeSuccessResponse()); + + $tempCreds = $this->makeTemporaryCredentials(); + $server->getTokenCredentials($tempCreds, 'temp_id', 'verifier'); + } + + /** + * @test + */ + public function getTokenCredentials_legacy_branch_uses_chained_send(): void + { + $server = $this->makeServer(); + + $response = $this->makeSuccessResponse('oauth_token=tok&oauth_token_secret=sec'); + + $pendingRequest = m::mock('stdClass'); + $pendingRequest->shouldReceive('send') + ->once() + ->andReturn($response); + + $legacyClient = m::mock('stdClass'); + $legacyClient->shouldReceive('post') + ->once() + ->with('test', m::type('array'), ['oauth_verifier' => 'verifier']) + ->andReturn($pendingRequest); + + $server->http = $legacyClient; + + $tempCreds = $this->makeTemporaryCredentials(); + $result = $server->getTokenCredentials($tempCreds, 'temp_id', 'verifier'); + + $this->assertInstanceOf(TokenCredentials::class, $result['tokenCredentials']); + $this->assertSame('tok', $result['tokenCredentials']->getIdentifier()); + } + + /** + * @test + */ + public function scopes_returns_self_for_fluent_chaining(): void + { + $server = $this->makeServer(); + + $this->assertSame($server, $server->scopes(['read'])); + } + + /** + * @test + */ + public function scopes_merges_and_deduplicates(): void + { + $server = $this->makeServer(); + $server->scopes(['read', 'write']); + $server->scopes(['read', 'admin']); + + $reflection = new \ReflectionClass($server); + $prop = $reflection->getProperty('scopes'); + $prop->setAccessible(true); + + $this->assertSame(['read', 'write', 'admin'], array_values($prop->getValue($server))); + } + + /** + * @test + */ + public function scopes_starts_empty(): void + { + $server = $this->makeServer(); + + $reflection = new \ReflectionClass($server); + $prop = $reflection->getProperty('scopes'); + $prop->setAccessible(true); + + $this->assertSame([], $prop->getValue($server)); + } + + /** + * @test + */ + public function with_returns_self_for_fluent_chaining(): void + { + $server = $this->makeServer(); + + $this->assertSame($server, $server->with(['foo' => 'bar'])); + } + + /** + * @test + */ + public function with_replaces_parameters_instead_of_merging(): void + { + $server = $this->makeServer(); + $server->with(['foo' => 'bar']); + $server->with(['baz' => 'qux']); + + $reflection = new \ReflectionClass($server); + $prop = $reflection->getProperty('parameters'); + $prop->setAccessible(true); + + $this->assertSame(['baz' => 'qux'], $prop->getValue($server)); + } + + /** + * @test + */ + public function with_starts_with_empty_parameters(): void + { + $server = $this->makeServer(); + + $reflection = new \ReflectionClass($server); + $prop = $reflection->getProperty('parameters'); + $prop->setAccessible(true); + + $this->assertSame([], $prop->getValue($server)); + } + + /** + * @test + */ + public function formatScopes_joins_with_comma_separator(): void + { + $server = $this->makeServer(); + + $this->assertSame('read,write,admin', $server->exposedFormatScopes(['read', 'write', 'admin'], ',')); + } + + /** + * @test + */ + public function formatScopes_joins_with_space_separator(): void + { + $server = $this->makeServer(); + + $this->assertSame('read write admin', $server->exposedFormatScopes(['read', 'write', 'admin'], ' ')); + } + + /** + * @test + */ + public function formatScopes_returns_empty_string_for_empty_array(): void + { + $server = $this->makeServer(); + + $this->assertSame('', $server->exposedFormatScopes([], ',')); + } + + /** + * @test + */ + public function formatScopes_handles_single_scope(): void + { + $server = $this->makeServer(); + + $this->assertSame('read', $server->exposedFormatScopes(['read'], ',')); + } +} diff --git a/tests/OAuth1UserTest.php b/tests/OAuth1UserTest.php new file mode 100644 index 0000000..96f9838 --- /dev/null +++ b/tests/OAuth1UserTest.php @@ -0,0 +1,31 @@ + 'tok', 'extra' => 'data']; + $user = (new User)->setAccessTokenResponseBody($credentialsBody); + + $this->assertSame($credentialsBody, $user->accessTokenResponseBody); + } + + /** + * @test + */ + public function setAccessTokenResponseBody_returns_self_for_fluent_chaining(): void + { + $user = new User; + $result = $user->setAccessTokenResponseBody(['test']); + + $this->assertSame($user, $result); + } +} diff --git a/tests/OAuth2AbstractProviderTest.php b/tests/OAuth2AbstractProviderTest.php new file mode 100644 index 0000000..7706a18 --- /dev/null +++ b/tests/OAuth2AbstractProviderTest.php @@ -0,0 +1,124 @@ + str_repeat('A', 40), + 'code' => 'code', + ]); + $request->setLaravelSession($session); + $session + ->shouldReceive('pull') + ->once() + ->with('state') + ->andReturn(str_repeat('A', 40)); + + return new OAuthTwoTestProviderStub($request, 'client_id', 'client_secret', 'redirect_uri'); + } + + protected function mockHttpResponse(OAuthTwoTestProviderStub $provider, string $body): void + { + $provider->http = m::mock(stdClass::class); + $provider->http + ->shouldReceive('post') + ->once() + ->andReturn($response = m::mock(stdClass::class)); + $response + ->shouldReceive('getBody') + ->andReturn($body); + } + + /** + * @test + */ + public function parseApprovedScopes_returns_empty_array_when_scope_is_null(): void + { + $provider = $this->makeProvider(); + $this->mockHttpResponse($provider, '{"access_token": "tok"}'); + + $user = $provider->user(); + + $this->assertSame([], $user->approvedScopes); + } + + /** + * @test + */ + public function parseApprovedScopes_returns_array_when_scope_is_array(): void + { + $provider = $this->makeProvider(); + $this->mockHttpResponse($provider, '{"access_token": "tok", "scope": ["read", "write"]}'); + + $user = $provider->user(); + + $this->assertSame(['read', 'write'], $user->approvedScopes); + } + + /** + * @test + */ + public function parseApprovedScopes_splits_string_scope_by_separator(): void + { + $provider = $this->makeProvider(); + $this->mockHttpResponse($provider, '{"access_token": "tok", "scope": "read,write,admin"}'); + + $user = $provider->user(); + + $this->assertSame(['read', 'write', 'admin'], $user->approvedScopes); + } + + /** + * @test + */ + public function parseApprovedScopes_returns_empty_array_for_numeric_scope(): void + { + $provider = $this->makeProvider(); + $this->mockHttpResponse($provider, '{"access_token": "tok", "scope": 42}'); + + $user = $provider->user(); + + $this->assertSame([], $user->approvedScopes); + } + + /** + * @test + */ + public function parseApprovedScopes_returns_empty_array_for_boolean_scope(): void + { + $provider = $this->makeProvider(); + $this->mockHttpResponse($provider, '{"access_token": "tok", "scope": true}'); + + $user = $provider->user(); + + $this->assertSame([], $user->approvedScopes); + } + + /** + * @test + */ + public function user_sets_refresh_token_and_expires_in(): void + { + $provider = $this->makeProvider(); + $this->mockHttpResponse($provider, '{"access_token": "tok", "refresh_token": "refresh", "expires_in": 3600}'); + + $user = $provider->user(); + + $this->assertSame('refresh', $user->refreshToken); + $this->assertSame(3600, $user->expiresIn); + } +} diff --git a/tests/ServiceProviderTest.php b/tests/ServiceProviderTest.php index a296bd3..96aaf81 100644 --- a/tests/ServiceProviderTest.php +++ b/tests/ServiceProviderTest.php @@ -2,8 +2,10 @@ namespace SocialiteProviders\Manager\Test; +use Illuminate\Contracts\Container\Container as ContainerContract; use Mockery as m; use PHPUnit\Framework\TestCase; +use SocialiteProviders\Manager\Contracts\Helpers\ConfigRetrieverInterface; use SocialiteProviders\Manager\ServiceProvider; use SocialiteProviders\Manager\SocialiteWasCalled; @@ -33,6 +35,74 @@ public function it_fires_an_event(): void $this->assertTrue(true); } + + /** + * @test + */ + public function it_fires_event_directly_for_lumen(): void + { + $socialiteWasCalledMock = m::mock(SocialiteWasCalled::class); + self::$functions + ->shouldReceive('app') + ->with(SocialiteWasCalled::class) + ->once() + ->andReturn($socialiteWasCalledMock); + + self::$functions + ->shouldReceive('event') + ->with($socialiteWasCalledMock) + ->once(); + + // Use a plain container mock (not Application), simulating Lumen + $app = m::mock(ContainerContract::class); + $app->shouldReceive('singleton')->zeroOrMoreTimes(); + $app->shouldReceive('bound')->andReturn(true); + + $serviceProvider = new ServiceProvider($app); + $serviceProvider->boot(); + + $this->assertTrue(true); + } + + /** + * @test + */ + public function register_binds_config_retriever_interface_as_singleton(): void + { + $app = $this->appMockWithBooted(); + $boundCalled = false; + $app->shouldReceive('singleton')->zeroOrMoreTimes(); + $app->shouldReceive('bound') + ->with(ConfigRetrieverInterface::class) + ->andReturnUsing(function () use (&$boundCalled) { + $boundCalled = true; + + return false; + }); + + $serviceProvider = new ServiceProvider($app); + $serviceProvider->register(); + + $this->assertTrue($boundCalled, 'ServiceProvider should check if ConfigRetrieverInterface is bound'); + } + + /** + * @test + */ + public function register_does_not_rebind_if_already_bound(): void + { + $app = $this->appMockWithBooted(); + $app->shouldReceive('singleton')->zeroOrMoreTimes(); + $app->shouldReceive('bound') + ->with(ConfigRetrieverInterface::class) + ->andReturn(true); + + $serviceProvider = new ServiceProvider($app); + $serviceProvider->register(); + + // verify the singleton was only called for the parent (Factory::class), not ConfigRetrieverInterface + $this->assertTrue(true); + } } namespace SocialiteProviders\Manager; diff --git a/tests/Stubs/OAuth1ServerStub.php b/tests/Stubs/OAuth1ServerStub.php index f222032..81cd615 100644 --- a/tests/Stubs/OAuth1ServerStub.php +++ b/tests/Stubs/OAuth1ServerStub.php @@ -8,6 +8,22 @@ class OAuth1ServerStub extends Server { + public $http; + + public function createHttpClient() + { + if ($this->http) { + return $this->http; + } + + return parent::createHttpClient(); + } + + public function exposedFormatScopes(array $scopes, string $separator): string + { + return $this->formatScopes($scopes, $separator); + } + /** * Get the URL for retrieving temporary credentials. *