diff --git a/.travis.yml b/.travis.yml index f3f94cc..6e414b9 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,10 +1,39 @@ language: php -php: - - 5.5 - - 5.6 - - 7.0 - - hhvm +matrix: + include: + - php: 5.5 + - php: 5.6 + - php: 7.0 + - php: nightly + - php: hhvm-3.6 + sudo: required + dist: trusty + group: edge + - php: hhvm-3.9 + sudo: required + dist: trusty + group: edge + - php: hhvm-3.12 + sudo: required + dist: trusty + group: edge + - php: hhvm-3.15 + sudo: required + dist: trusty + group: edge + - php: hhvm-nightly + sudo: required + dist: trusty + group: edge + fast_finish: true + allow_failures: + - php: nightly + - php: hhvm-3.6 + - php: hhvm-3.9 + - php: hhvm-3.12 + - php: hhvm-3.15 + - php: hhvm-nightly before_script: - travis_retry composer self-update @@ -16,5 +45,5 @@ script: - ./vendor/bin/phpunit --coverage-text --coverage-clover=coverage.clover after_script: - - wget https://scrutinizer-ci.com/ocular.phar - - php ocular.phar code-coverage:upload --format=php-clover coverage.clover + - if [ "$TRAVIS_PHP_VERSION" == "7.1" ]; then wget https://scrutinizer-ci.com/ocular.phar; fi + - if [ "$TRAVIS_PHP_VERSION" == "7.1" ]; then php ocular.phar code-coverage:upload --format=php-clover coverage.clover; fi diff --git a/composer.json b/composer.json index 5a82c83..8f946f7 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,7 @@ "linkedin" ], "require": { - "php": ">=5.5.0", + "php": ">=5.5.0 <7.1", "league/oauth2-client": "~1.0" }, "require-dev": { diff --git a/src/Provider/Exception/LinkedInAccessDeniedException.php b/src/Provider/Exception/LinkedInAccessDeniedException.php new file mode 100644 index 0000000..c5fe406 --- /dev/null +++ b/src/Provider/Exception/LinkedInAccessDeniedException.php @@ -0,0 +1,8 @@ +fields); + $query = http_build_query([ + 'projection' => '(' . implode(',', $this->fields) . ')' + ]); - return 'https://api.linkedin.com/v1/people/~:(' . $fields . ')?format=json'; + return 'https://api.linkedin.com/v2/me?' . urldecode($query); + } + + /** + * Get provider url to fetch user details + * + * @param AccessToken $token + * + * @return string + */ + public function getResourceOwnerEmailUrl(AccessToken $token) + { + $query = http_build_query([ + 'q' => 'members', + 'projection' => '(elements*(state,primary,type,handle~))' + ]); + + return 'https://api.linkedin.com/v2/clientAwareMemberHandles?' . urldecode($query); } /** @@ -88,16 +149,39 @@ protected function getDefaultScopes() /** * Check a provider response for errors. * - * @throws IdentityProviderException * @param ResponseInterface $response - * @param string $data Parsed response data + * @param array $data Parsed response data * @return void + * @throws IdentityProviderException + * @see https://developer.linkedin.com/docs/guide/v2/error-handling */ protected function checkResponse(ResponseInterface $response, $data) { - if (isset($data['error'])) { + $this->checkResponseUnauthorized($response, $data); + + if ($response->getStatusCode() >= 400) { throw new IdentityProviderException( - $data['error_description'] ?: $response->getReasonPhrase(), + $data['message'] ?: $response->getReasonPhrase(), + $data['status'] ?: $response->getStatusCode(), + $response + ); + } + } + + /** + * Check a provider response for unauthorized errors. + * + * @param ResponseInterface $response + * @param array $data Parsed response data + * @return void + * @throws LinkedInAccessDeniedException + * @see https://developer.linkedin.com/docs/guide/v2/error-handling + */ + protected function checkResponseUnauthorized(ResponseInterface $response, $data) + { + if (isset($data['status']) && $data['status'] === 403) { + throw new LinkedInAccessDeniedException( + $data['message'] ?: $response->getReasonPhrase(), $response->getStatusCode(), $response ); @@ -109,10 +193,84 @@ protected function checkResponse(ResponseInterface $response, $data) * * @param array $response * @param AccessToken $token - * @return League\OAuth2\Client\Provider\ResourceOwnerInterface + * @return LinkedInResourceOwner */ protected function createResourceOwner(array $response, AccessToken $token) { - return new LinkedInResourceOwner($response); + // If current accessToken is not authorized with r_emailaddress scope, + // getResourceOwnerEmail will throw LinkedInAccessDeniedException, it will be caught here, + // and then the email will be set to null + // When email is not available due to chosen scopes, other providers simply set it to null, let's do the same. + try { + $email = $this->getResourceOwnerEmail($token); + } catch (LinkedInAccessDeniedException $exception) { + $email = null; + } + + return new LinkedInResourceOwner($response, $email); + } + + /** + * Returns the requested fields in scope. + * + * @return array + */ + public function getFields() + { + return $this->fields; + } + + /** + * Attempts to fetch resource owner's email address via separate API request. + * + * @param AccessToken $token [description] + * @return string|null + * @throws IdentityProviderException + */ + public function getResourceOwnerEmail(AccessToken $token) + { + $emailUrl = $this->getResourceOwnerEmailUrl($token); + $emailRequest = $this->getAuthenticatedRequest(self::METHOD_GET, $emailUrl, $token); + $emailResponse = $this->getResponse($emailRequest); + + return $this->extractEmailFromResponse($emailResponse); + } + + /** + * Updates the requested fields in scope. + * + * @param array $fields + * + * @return LinkedIn + */ + public function withFields(array $fields) + { + $this->fields = $fields; + + return $this; + } + + /** + * Attempts to extract the email address from a valid email api response. + * + * @param array $response + * @return string|null + */ + protected function extractEmailFromResponse($response = []) + { + try { + $confirmedEmails = array_filter($response['elements'], function ($element) { + return + strtoupper($element['type']) === 'EMAIL' + && strtoupper($element['state']) === 'CONFIRMED' + && $element['primary'] === true + && isset($element['handle~']['emailAddress']) + ; + }); + + return $confirmedEmails[0]['handle~']['emailAddress']; + } catch (Exception $e) { + return null; + } } } diff --git a/src/Provider/LinkedInResourceOwner.php b/src/Provider/LinkedInResourceOwner.php index 34aaba6..19f8236 100644 --- a/src/Provider/LinkedInResourceOwner.php +++ b/src/Provider/LinkedInResourceOwner.php @@ -16,86 +16,121 @@ class LinkedInResourceOwner extends GenericResourceOwner * * @var array */ - protected $response; + protected $response = []; + + /** + * Sorted profile pictures + * + * @var array + */ + protected $sortedProfilePictures = []; + + /** + * @var string|null + */ + private $email; /** * Creates new resource owner. * * @param array $response + * @param string|null $email */ - public function __construct(array $response = array()) + public function __construct(array $response = array(), $email = null) { $this->response = $response; + $this->email = $email; + $this->setSortedProfilePictures(); } /** - * Get user email + * Gets resource owner attribute by key. The key supports dot notation. * - * @return string|null + * @return mixed */ - public function getEmail() + public function getAttribute($key) { - return $this->getValueByKey($this->response, 'emailAddress'); + return $this->getValueByKey($this->response, (string) $key); } /** - * Get user firstname + * Get user first name * * @return string|null */ public function getFirstName() { - return $this->getValueByKey($this->response, 'firstName'); + return $this->getAttribute('localizedFirstName'); } /** - * Get user imageurl + * Get user user id * * @return string|null */ - public function getImageurl() + public function getId() { - return $this->getValueByKey($this->response, 'pictureUrl'); + return $this->getAttribute('id'); } /** - * Get user lastname + * Get specific image by size * - * @return string|null + * @param integer $size + * @return array|null */ - public function getLastName() + public function getImageBySize($size) { - return $this->getValueByKey($this->response, 'lastName'); + $pictures = array_filter($this->sortedProfilePictures, function ($picture) use ($size) { + return isset($picture['width']) && $picture['width'] == $size; + }); + + return count($pictures) ? $pictures[0] : null; } /** - * Get user userId + * Get available user image sizes * - * @return string|null + * @return array */ - public function getId() + public function getImageSizes() { - return $this->getValueByKey($this->response, 'id'); + return array_map(function ($picture) { + return $this->getValueByKey($picture, 'width'); + }, $this->sortedProfilePictures); } /** - * Get user location + * Get user image url * * @return string|null */ - public function getLocation() + public function getImageUrl() { - return $this->getValueByKey($this->response, 'location.name'); + $pictures = $this->getSortedProfilePictures(); + $picture = array_pop($pictures); + + return $picture ? $this->getValueByKey($picture, 'url') : null; } /** - * Get user description + * Get user last name * * @return string|null */ - public function getDescription() + public function getLastName() + { + return $this->getAttribute('localizedLastName'); + } + + /** + * Returns the sorted collection of profile pictures. + * + * @return array + */ + public function getSortedProfilePictures() { - return $this->getValueByKey($this->response, 'headline'); + return $this->sortedProfilePictures; } /** @@ -105,7 +140,61 @@ public function getDescription() */ public function getUrl() { - return $this->getValueByKey($this->response, 'publicProfileUrl'); + $vanityName = $this->getAttribute('vanityName'); + + return $vanityName ? sprintf('https://www.linkedin.com/in/%s', $vanityName) : null; + } + + /** + * Get user email, if available + * + * @return string|null + */ + public function getEmail() + { + return $this->email; + } + + /** + * Attempts to sort the collection of profile pictures included in the profile + * before caching them in the resource owner instance. + * + * @return void + */ + private function setSortedProfilePictures() + { + $pictures = $this->getAttribute('profilePicture.displayImage~.elements'); + if (is_array($pictures)) { + $pictures = array_filter($pictures, function ($element) { + // filter to public images only + return + isset($element['data']['com.linkedin.digitalmedia.mediaartifact.StillImage']) + && strtoupper($element['authorizationMethod']) === 'PUBLIC' + && isset($element['identifiers'][0]['identifier']) + ; + }); + // order images by width, LinkedIn profile pictures are always squares, so that should be good enough + usort($pictures, function ($elementA, $elementB) { + $wA = $elementA['data']['com.linkedin.digitalmedia.mediaartifact.StillImage']['storageSize']['width']; + $wB = $elementB['data']['com.linkedin.digitalmedia.mediaartifact.StillImage']['storageSize']['width']; + return $wA - $wB; + }); + $pictures = array_map(function ($element) { + // this is an URL, no idea how many of identifiers there can be, so take the first one. + $url = $element['identifiers'][0]['identifier']; + $type = $element['identifiers'][0]['mediaType']; + $width = $element['data']['com.linkedin.digitalmedia.mediaartifact.StillImage']['storageSize']['width']; + return [ + 'width' => $width, + 'url' => $url, + 'contentType' => $type, + ]; + }, $pictures); + } else { + $pictures = []; + } + + $this->sortedProfilePictures = $pictures; } /** diff --git a/src/Token/LinkedInAccessToken.php b/src/Token/LinkedInAccessToken.php new file mode 100644 index 0000000..c9dcb41 --- /dev/null +++ b/src/Token/LinkedInAccessToken.php @@ -0,0 +1,41 @@ +isExpirationTimestamp($expires)) { + $expires += time(); + } + $this->refreshTokenExpires = $expires; + } + } + + /** + * Returns the refresh token expiration timestamp, if defined. + * + * @return integer|null + */ + public function getRefreshTokenExpires() + { + return $this->refreshTokenExpires; + } +} diff --git a/test/api_responses/email.json b/test/api_responses/email.json new file mode 100644 index 0000000..7fd0bd2 --- /dev/null +++ b/test/api_responses/email.json @@ -0,0 +1,13 @@ +{ + "elements": [ + { + "handle": "urn:li:emailAddress:", + "state": "CONFIRMED", + "type": "EMAIL", + "handle~": { + "emailAddress": "resource-owner@example.com" + }, + "primary": true + } + ] +} diff --git a/test/api_responses/me.json b/test/api_responses/me.json new file mode 100644 index 0000000..5d2ef3e --- /dev/null +++ b/test/api_responses/me.json @@ -0,0 +1,186 @@ +{ + "localizedLastName": "Doe", + "profilePicture": { + "displayImage": "urn:li:digitalmediaAsset:", + "displayImage~": { + "elements": [ + { + "artifact": "urn:li:digitalmediaMediaArtifact:(urn:li:digitalmediaAsset:,urn:li:digitalmediaMediaArtifactClass:profile-displayphoto-shrink_100_100)", + "authorizationMethod": "PUBLIC", + "data": { + "com.linkedin.digitalmedia.mediaartifact.StillImage": { + "storageSize": { + "width": 100, + "height": 100 + }, + "storageAspectRatio": { + "widthAspect": 1.0, + "heightAspect": 1.0, + "formatted": "1.00:1.00" + }, + "mediaType": "image/jpeg", + "rawCodecSpec": { + "name": "jpeg", + "type": "image" + }, + "displaySize": { + "uom": "PX", + "width": 100.0, + "height": 100.0 + }, + "displayAspectRatio": { + "widthAspect": 1.0, + "heightAspect": 1.0, + "formatted": "1.00:1.00" + } + } + }, + "identifiers": [ + { + "identifier": "http://example.com/avatar_100_100.jpeg", + "file": "urn:li:digitalmediaFile:(urn:li:digitalmediaAsset:,urn:li:digitalmediaMediaArtifactClass:profile-displayphoto-shrink_100_100,0)", + "index": 0, + "mediaType": "image/jpeg", + "identifierType": "EXTERNAL_URL", + "identifierExpiresInSeconds": 1561593600 + } + ] + }, + { + "artifact": "urn:li:digitalmediaMediaArtifact:(urn:li:digitalmediaAsset:,urn:li:digitalmediaMediaArtifactClass:profile-displayphoto-shrink_200_200)", + "authorizationMethod": "PUBLIC", + "data": { + "com.linkedin.digitalmedia.mediaartifact.StillImage": { + "storageSize": { + "width": 200, + "height": 200 + }, + "storageAspectRatio": { + "widthAspect": 1.0, + "heightAspect": 1.0, + "formatted": "1.00:1.00" + }, + "mediaType": "image/jpeg", + "rawCodecSpec": { + "name": "jpeg", + "type": "image" + }, + "displaySize": { + "uom": "PX", + "width": 200.0, + "height": 200.0 + }, + "displayAspectRatio": { + "widthAspect": 1.0, + "heightAspect": 1.0, + "formatted": "1.00:1.00" + } + } + }, + "identifiers": [ + { + "identifier": "http://example.com/avatar_200_200.jpeg", + "file": "urn:li:digitalmediaFile:(urn:li:digitalmediaAsset:,urn:li:digitalmediaMediaArtifactClass:profile-displayphoto-shrink_200_200,0)", + "index": 0, + "mediaType": "image/jpeg", + "identifierType": "EXTERNAL_URL", + "identifierExpiresInSeconds": 1561593600 + } + ] + }, + { + "artifact": "urn:li:digitalmediaMediaArtifact:(urn:li:digitalmediaAsset:,urn:li:digitalmediaMediaArtifactClass:profile-displayphoto-shrink_400_400)", + "authorizationMethod": "PUBLIC", + "data": { + "com.linkedin.digitalmedia.mediaartifact.StillImage": { + "storageSize": { + "width": 400, + "height": 400 + }, + "storageAspectRatio": { + "widthAspect": 1.0, + "heightAspect": 1.0, + "formatted": "1.00:1.00" + }, + "mediaType": "image/jpeg", + "rawCodecSpec": { + "name": "jpeg", + "type": "image" + }, + "displaySize": { + "uom": "PX", + "width": 400.0, + "height": 400.0 + }, + "displayAspectRatio": { + "widthAspect": 1.0, + "heightAspect": 1.0, + "formatted": "1.00:1.00" + } + } + }, + "identifiers": [ + { + "identifier": "http://example.com/avatar_400_400.jpeg", + "file": "urn:li:digitalmediaFile:(urn:li:digitalmediaAsset:,urn:li:digitalmediaMediaArtifactClass:profile-displayphoto-shrink_400_400,0)", + "index": 0, + "mediaType": "image/jpeg", + "identifierType": "EXTERNAL_URL", + "identifierExpiresInSeconds": 1561593600 + } + ] + }, + { + "artifact": "urn:li:digitalmediaMediaArtifact:(urn:li:digitalmediaAsset:,urn:li:digitalmediaMediaArtifactClass:profile-displayphoto-shrink_800_800)", + "authorizationMethod": "PUBLIC", + "data": { + "com.linkedin.digitalmedia.mediaartifact.StillImage": { + "storageSize": { + "width": 800, + "height": 800 + }, + "storageAspectRatio": { + "widthAspect": 1.0, + "heightAspect": 1.0, + "formatted": "1.00:1.00" + }, + "mediaType": "image/jpeg", + "rawCodecSpec": { + "name": "jpeg", + "type": "image" + }, + "displaySize": { + "uom": "PX", + "width": 800.0, + "height": 800.0 + }, + "displayAspectRatio": { + "widthAspect": 1.0, + "heightAspect": 1.0, + "formatted": "1.00:1.00" + } + } + }, + "identifiers": [ + { + "identifier": "http://example.com/avatar_800_800.jpeg", + "file": "urn:li:digitalmediaFile:(urn:li:digitalmediaAsset:,urn:li:digitalmediaMediaArtifactClass:profile-displayphoto-shrink_800_800,0)", + "index": 0, + "mediaType": "image/jpeg", + "identifierType": "EXTERNAL_URL", + "identifierExpiresInSeconds": 1561593600 + } + ] + } + ], + "paging": { + "count": 10, + "start": 0, + "links": [] + } + } + }, + "id": "abcdef1234", + "localizedFirstName": "John", + "vanityName": "john-doe" +} diff --git a/test/src/Provider/LinkedInTest.php b/test/src/Provider/LinkedInTest.php index 928c37b..a09fc06 100644 --- a/test/src/Provider/LinkedInTest.php +++ b/test/src/Provider/LinkedInTest.php @@ -1,9 +1,13 @@ assertNotNull($this->provider->getState()); } + public function testResourceOwnerDetailsUrl() + { + $accessToken = m::mock('League\OAuth2\Client\Token\AccessToken'); + $expectedFields = $this->provider->getFields(); + $url = $this->provider->getResourceOwnerDetailsUrl($accessToken); + $uri = parse_url($url); + $path = $uri['path']; + $query = explode('=', $uri['query']); + $fields = $query[1]; + $actualFields = explode(',', preg_replace('/^\((.*)\)$/', '\1', $fields)); + $this->assertEquals('/v2/me', $path); + $this->assertEquals('projection', $query[0]); + $this->assertEquals($expectedFields, $actualFields); + } - public function testScopes() + public function testResourceOwnerEmailUrl() { - $options = ['scope' => [uniqid(),uniqid()]]; + $accessToken = m::mock('League\OAuth2\Client\Token\AccessToken'); + $expectedFields = $this->provider->getFields(); + $url = $this->provider->getResourceOwnerEmailUrl($accessToken); + $uri = parse_url($url); + parse_str($uri['query'], $query); + $this->assertEquals('/v2/clientAwareMemberHandles', $uri['path']); + $this->assertEquals('(elements*(state,primary,type,handle~))', $query['projection']); + } + public function testScopes() + { + $scopeSeparator = ' '; + $options = ['scope' => [uniqid(), uniqid()]]; + $query = ['scope' => implode($scopeSeparator, $options['scope'])]; $url = $this->provider->getAuthorizationUrl($options); + $encodedScope = $this->buildQueryString($query); + $this->assertContains($encodedScope, $url); + } + + public function testFields() + { + $provider = new \League\OAuth2\Client\Provider\LinkedIn([ + 'clientId' => 'mock_client_id', + 'clientSecret' => 'mock_secret', + 'redirectUri' => 'none' + ]); + + $currentFields = $provider->getFields(); + $customFields = [uniqid(), uniqid()]; + + $this->assertTrue(is_array($currentFields)); + $provider->withFields($customFields); + $this->assertEquals($customFields, $provider->getFields()); + } - $this->assertContains(urlencode(implode(' ', $options['scope'])), $url); + public function testNonArrayFieldsDuringInstantiationThrowsException() + { + $this->setExpectedException(InvalidArgumentException::class); + $provider = new \League\OAuth2\Client\Provider\LinkedIn([ + 'clientId' => 'mock_client_id', + 'clientSecret' => 'mock_secret', + 'redirectUri' => 'none', + 'fields' => 'foo' + ]); } public function testGetAuthorizationUrl() @@ -51,7 +108,7 @@ public function testGetAuthorizationUrl() $url = $this->provider->getAuthorizationUrl(); $uri = parse_url($url); - $this->assertEquals('/uas/oauth2/authorization', $uri['path']); + $this->assertEquals('/oauth/v2/authorization', $uri['path']); } public function testGetBaseAccessTokenUrl() @@ -61,14 +118,15 @@ public function testGetBaseAccessTokenUrl() $url = $this->provider->getBaseAccessTokenUrl($params); $uri = parse_url($url); - $this->assertEquals('/uas/oauth2/accessToken', $uri['path']); + $this->assertEquals('/oauth/v2/accessToken', $uri['path']); } public function testGetAccessToken() { $response = m::mock('Psr\Http\Message\ResponseInterface'); - $response->shouldReceive('getBody')->andReturn('{"access_token": "mock_access_token", "expires_in": 3600}'); + $response->shouldReceive('getBody')->andReturn('{"access_token": "mock_access_token", "expires_in": 3600, "refresh_token": "mock_refresh_token", "refresh_token_expires_in": 7200}'); $response->shouldReceive('getHeader')->andReturn(['content-type' => 'json']); + $response->shouldReceive('getStatusCode')->andReturn(200); $client = m::mock('GuzzleHttp\ClientInterface'); $client->shouldReceive('send')->times(1)->andReturn($response); @@ -79,98 +137,221 @@ public function testGetAccessToken() $this->assertEquals('mock_access_token', $token->getToken()); $this->assertLessThanOrEqual(time() + 3600, $token->getExpires()); $this->assertGreaterThanOrEqual(time(), $token->getExpires()); - $this->assertNull($token->getRefreshToken()); + $this->assertEquals('mock_refresh_token', $token->getRefreshToken()); + $this->assertLessThanOrEqual(time() + 7200, $token->getRefreshTokenExpires()); + $this->assertGreaterThanOrEqual(time(), $token->getRefreshTokenExpires()); $this->assertNull($token->getResourceOwnerId()); } public function testUserData() { - $email = uniqid(); - $userId = rand(1000,9999); - $firstName = uniqid(); - $lastName = uniqid(); - $picture = uniqid(); - $location = uniqid(); - $url = uniqid(); - $description = uniqid(); + $apiProfileResponse = json_decode(file_get_contents(__DIR__.'/../../api_responses/me.json'), true); + $apiEmailResponse = json_decode(file_get_contents(__DIR__.'/../../api_responses/email.json'), true); + $somethingExtra = ['more' => uniqid()]; + $apiProfileResponse['somethingExtra'] = $somethingExtra; $postResponse = m::mock('Psr\Http\Message\ResponseInterface'); $postResponse->shouldReceive('getBody')->andReturn('{"access_token": "mock_access_token", "expires_in": 3600}'); $postResponse->shouldReceive('getHeader')->andReturn(['content-type' => 'json']); + $postResponse->shouldReceive('getStatusCode')->andReturn(200); $userResponse = m::mock('Psr\Http\Message\ResponseInterface'); - $userResponse->shouldReceive('getBody')->andReturn('{"id": '.$userId.', "firstName": "'.$firstName.'", "lastName": "'.$lastName.'", "emailAddress": "'.$email.'", "location": { "name": "'.$location.'" }, "headline": "'.$description.'", "pictureUrl": "'.$picture.'", "publicProfileUrl": "'.$url.'"}'); + $userResponse->shouldReceive('getBody')->andReturn(json_encode($apiProfileResponse)); $userResponse->shouldReceive('getHeader')->andReturn(['content-type' => 'json']); + $userResponse->shouldReceive('getStatusCode')->andReturn(200); + + $emailResponse = m::mock('Psr\Http\Message\ResponseInterface'); + $emailResponse->shouldReceive('getBody')->andReturn(json_encode($apiEmailResponse)); + $emailResponse->shouldReceive('getHeader')->andReturn(['content-type' => 'json']); + $emailResponse->shouldReceive('getStatusCode')->andReturn(200); $client = m::mock('GuzzleHttp\ClientInterface'); $client->shouldReceive('send') - ->times(2) - ->andReturn($postResponse, $userResponse); + ->times(3) + ->andReturn($postResponse, $userResponse, $emailResponse); $this->provider->setHttpClient($client); $token = $this->provider->getAccessToken('authorization_code', ['code' => 'mock_authorization_code']); $user = $this->provider->getResourceOwner($token); - $this->assertEquals($email, $user->getEmail()); - $this->assertEquals($email, $user->toArray()['emailAddress']); - $this->assertEquals($userId, $user->getId()); - $this->assertEquals($userId, $user->toArray()['id']); - $this->assertEquals($firstName, $user->getFirstName()); - $this->assertEquals($firstName, $user->toArray()['firstName']); - $this->assertEquals($lastName, $user->GeTlAsTnAmE()); // https://github.com/thephpleague/oauth2-linkedin/issues/4 - $this->assertEquals($lastName, $user->toArray()['lastName']); - $this->assertEquals($picture, $user->getImageurl()); - $this->assertEquals($picture, $user->toArray()['pictureUrl']); - $this->assertEquals($location, $user->getLocation()); - $this->assertEquals($location, $user->toArray()['location']['name']); - $this->assertEquals($url, $user->getUrl()); - $this->assertEquals($url, $user->toArray()['publicProfileUrl']); - $this->assertEquals($description, $user->getDescription()); - $this->assertEquals($description, $user->toArray()['headline']); + $this->assertEquals('abcdef1234', $user->getId()); + $this->assertEquals('abcdef1234', $user->toArray()['id']); + $this->assertEquals('John', $user->getFirstName()); + $this->assertEquals('John', $user->toArray()['localizedFirstName']); + $this->assertEquals('Doe', $user->getLastName()); + $this->assertEquals('Doe', $user->toArray()['localizedLastName']); + $this->assertEquals('http://example.com/avatar_800_800.jpeg', $user->getImageUrl()); + $this->assertEquals('https://www.linkedin.com/in/john-doe', $user->getUrl()); + $this->assertEquals('resource-owner@example.com', $user->getEmail()); + $this->assertEquals($somethingExtra, $user->getAttribute('somethingExtra')); + $this->assertEquals($somethingExtra, $user->toArray()['somethingExtra']); + $this->assertEquals($somethingExtra['more'], $user->getAttribute('somethingExtra.more')); + $this->assertEquals([100, 200, 400, 800], $user->getImageSizes()); + $this->assertTrue(is_array($user->getImageBySize(100))); + $this->assertNull($user->getImageBySize(300)); } public function testMissingUserData() { - $email = uniqid(); $userId = rand(1000,9999); $firstName = uniqid(); $lastName = uniqid(); - $location = uniqid(); - $url = uniqid(); - $description = uniqid(); + $apiProfileResponse = json_decode(file_get_contents(__DIR__.'/../../api_responses/me.json'), true); + $apiEmailResponse = json_decode(file_get_contents(__DIR__.'/../../api_responses/email.json'), true); + $apiProfileResponse['id'] = $userId; + $apiProfileResponse['localizedFirstName'] = $firstName; + $apiProfileResponse['localizedLastName'] = $lastName; + unset($apiProfileResponse['profilePicture']); + unset($apiProfileResponse['vanityName']); $postResponse = m::mock('Psr\Http\Message\ResponseInterface'); $postResponse->shouldReceive('getBody')->andReturn('{"access_token": "mock_access_token", "expires_in": 3600}'); $postResponse->shouldReceive('getHeader')->andReturn(['content-type' => 'json']); + $postResponse->shouldReceive('getStatusCode')->andReturn(200); $userResponse = m::mock('Psr\Http\Message\ResponseInterface'); - $userResponse->shouldReceive('getBody')->andReturn('{"id": '.$userId.', "firstName": "'.$firstName.'", "lastName": "'.$lastName.'", "emailAddress": "'.$email.'", "location": { "name": "'.$location.'" }, "headline": "'.$description.'", "publicProfileUrl": "'.$url.'"}'); + $userResponse->shouldReceive('getBody')->andReturn(json_encode($apiProfileResponse)); $userResponse->shouldReceive('getHeader')->andReturn(['content-type' => 'json']); + $userResponse->shouldReceive('getStatusCode')->andReturn(200); + + $emailResponse = m::mock('Psr\Http\Message\ResponseInterface'); + $emailResponse->shouldReceive('getBody')->andReturn(json_encode($apiEmailResponse)); + $emailResponse->shouldReceive('getHeader')->andReturn(['content-type' => 'json']); + $emailResponse->shouldReceive('getStatusCode')->andReturn(200); $client = m::mock('GuzzleHttp\ClientInterface'); $client->shouldReceive('send') - ->times(2) - ->andReturn($postResponse, $userResponse); + ->times(3) + ->andReturn($postResponse, $userResponse, $emailResponse); $this->provider->setHttpClient($client); $token = $this->provider->getAccessToken('authorization_code', ['code' => 'mock_authorization_code']); $user = $this->provider->getResourceOwner($token); - $this->assertEquals($email, $user->getEmail()); - $this->assertEquals($email, $user->toArray()['emailAddress']); $this->assertEquals($userId, $user->getId()); $this->assertEquals($userId, $user->toArray()['id']); $this->assertEquals($firstName, $user->getFirstName()); - $this->assertEquals($firstName, $user->toArray()['firstName']); + $this->assertEquals($firstName, $user->toArray()['localizedFirstName']); $this->assertEquals($lastName, $user->GeTlAsTnAmE()); // https://github.com/thephpleague/oauth2-linkedin/issues/4 - $this->assertEquals($lastName, $user->toArray()['lastName']); + $this->assertEquals($lastName, $user->toArray()['localizedLastName']); $this->assertEquals(null, $user->getImageurl()); - $this->assertEquals($location, $user->getLocation()); - $this->assertEquals($location, $user->toArray()['location']['name']); - $this->assertEquals($url, $user->getUrl()); - $this->assertEquals($url, $user->toArray()['publicProfileUrl']); - $this->assertEquals($description, $user->getDescription()); - $this->assertEquals($description, $user->toArray()['headline']); + $this->assertEquals(null, $user->getUrl()); + } + + public function testUserEmail() + { + $apiEmailResponse = json_decode(file_get_contents(__DIR__.'/../../api_responses/email.json'), true); + + $postResponse = m::mock('Psr\Http\Message\ResponseInterface'); + $postResponse->shouldReceive('getBody')->andReturn('{"access_token": "mock_access_token", "expires_in": 3600}'); + $postResponse->shouldReceive('getHeader')->andReturn(['content-type' => 'json']); + $postResponse->shouldReceive('getStatusCode')->andReturn(200); + + $userResponse = m::mock('Psr\Http\Message\ResponseInterface'); + $userResponse->shouldReceive('getBody')->andReturn(json_encode($apiEmailResponse)); + $userResponse->shouldReceive('getHeader')->andReturn(['content-type' => 'json']); + $userResponse->shouldReceive('getStatusCode')->andReturn(200); + + $client = m::mock('GuzzleHttp\ClientInterface'); + $client->shouldReceive('send') + ->times(2) + ->andReturn($postResponse, $userResponse); + $this->provider->setHttpClient($client); + + $token = $this->provider->getAccessToken('authorization_code', ['code' => 'mock_authorization_code']); + $email = $this->provider->getResourceOwnerEmail($token); + + $this->assertEquals('resource-owner@example.com', $email); + } + + public function testUserEmailNullIfApiResponseInvalid() + { + foreach ([null, []] as $apiEmailResponse) { + $postResponse = m::mock('Psr\Http\Message\ResponseInterface'); + $postResponse->shouldReceive('getBody')->andReturn('{"access_token": "mock_access_token", "expires_in": 3600}'); + $postResponse->shouldReceive('getHeader')->andReturn(['content-type' => 'json']); + $postResponse->shouldReceive('getStatusCode')->andReturn(200); + + $emailResponse = m::mock('Psr\Http\Message\ResponseInterface'); + $emailResponse->shouldReceive('getBody')->andReturn(json_encode($apiEmailResponse)); + $emailResponse->shouldReceive('getHeader')->andReturn(['content-type' => 'json']); + $emailResponse->shouldReceive('getStatusCode')->andReturn(200); + + $client = m::mock('GuzzleHttp\ClientInterface'); + $client->shouldReceive('send') + ->times(2) + ->andReturn($postResponse, $emailResponse); + $this->provider->setHttpClient($client); + + $token = $this->provider->getAccessToken('authorization_code', ['code' => 'mock_authorization_code']); + $email = $this->provider->getResourceOwnerEmail($token); + + $this->assertNull($email); + } + } + + public function testResourceOwnerEmailNullWhenNotAuthorized() + { + $apiProfileResponse = json_decode(file_get_contents(__DIR__.'/../../api_responses/me.json'), true); + + $postResponse = m::mock('Psr\Http\Message\ResponseInterface'); + $postResponse->shouldReceive('getBody')->andReturn('{"access_token": "mock_access_token", "expires_in": 3600}'); + $postResponse->shouldReceive('getHeader')->andReturn(['content-type' => 'json']); + $postResponse->shouldReceive('getStatusCode')->andReturn(200); + + $userResponse = m::mock('Psr\Http\Message\ResponseInterface'); + $userResponse->shouldReceive('getBody')->andReturn(json_encode($apiProfileResponse)); + $userResponse->shouldReceive('getHeader')->andReturn(['content-type' => 'json']); + $userResponse->shouldReceive('getStatusCode')->andReturn(200); + + $emailResponse = m::mock('Psr\Http\Message\ResponseInterface'); + $emailResponse->shouldReceive('getBody')->andReturn('{"message": "Not enough permissions to access: GET-members /clientAwareMemberHandles","status":403,"serviceErrorCode":100}'); + $emailResponse->shouldReceive('getHeader')->andReturn(['content-type' => 'json']); + $emailResponse->shouldReceive('getStatusCode')->andReturn(403); + + $client = m::mock('GuzzleHttp\ClientInterface'); + $client->shouldReceive('send') + ->times(3) + ->andReturn($postResponse, $userResponse, $emailResponse); + $this->provider->setHttpClient($client); + + $token = $this->provider->getAccessToken('authorization_code', ['code' => 'mock_authorization_code']); + + $user = $this->provider->getResourceOwner($token); + + $this->assertNull($user->getEmail()); + + $this->assertEquals('abcdef1234', $user->getId()); + $this->assertEquals('John', $user->getFirstName()); + $this->assertEquals('Doe', $user->getLastName()); + $this->assertEquals('http://example.com/avatar_800_800.jpeg', $user->getImageUrl()); + $this->assertEquals('https://www.linkedin.com/in/john-doe', $user->getUrl()); + } + + /** + * @expectedException League\OAuth2\Client\Provider\Exception\LinkedInAccessDeniedException + */ + public function testExceptionThrownWhenEmailIsNotAuthorizedButRequestedFromAdapter() + { + $postResponse = m::mock('Psr\Http\Message\ResponseInterface'); + $postResponse->shouldReceive('getBody')->andReturn('{"access_token": "mock_access_token", "expires_in": 3600}'); + $postResponse->shouldReceive('getHeader')->andReturn(['content-type' => 'json']); + $postResponse->shouldReceive('getStatusCode')->andReturn(200); + + $emailResponse = m::mock('Psr\Http\Message\ResponseInterface'); + $emailResponse->shouldReceive('getBody')->andReturn('{"message": "Not enough permissions to access: GET-members /clientAwareMemberHandles","status":403,"serviceErrorCode":100}'); + $emailResponse->shouldReceive('getHeader')->andReturn(['content-type' => 'json']); + $emailResponse->shouldReceive('getStatusCode')->andReturn(403); + + $client = m::mock('GuzzleHttp\ClientInterface'); + $client->shouldReceive('send') + ->times(2) + ->andReturn($postResponse, $emailResponse); + $this->provider->setHttpClient($client); + + $token = $this->provider->getAccessToken('authorization_code', ['code' => 'mock_authorization_code']); + + $this->provider->getResourceOwnerEmail($token); } /** @@ -181,7 +362,7 @@ public function testExceptionThrownWhenErrorObjectReceived() $message = uniqid(); $status = rand(400,600); $postResponse = m::mock('Psr\Http\Message\ResponseInterface'); - $postResponse->shouldReceive('getBody')->andReturn('{"error_description": "'.$message.'","error": "invalid_request"}'); + $postResponse->shouldReceive('getBody')->andReturn('{"message": "'.$message.'","status": '.$status.', "serviceErrorCode": 100}'); $postResponse->shouldReceive('getHeader')->andReturn(['content-type' => 'json']); $postResponse->shouldReceive('getStatusCode')->andReturn($status);