From e8ca1c08344e369898a88815e51ceda7bef64c71 Mon Sep 17 00:00:00 2001 From: akkie Date: Sun, 2 Mar 2014 17:08:56 +0100 Subject: [PATCH] Tests for Google provider and support for new API --- .../providers/oauth2/GoogleProvider.scala | 50 +++-- .../core/providers/OAuth2ProviderSpec.scala | 6 +- .../providers/oauth2/GoogleProviderSpec.scala | 176 ++++++++++++++++++ .../providers/oauth2/google.error.json | 15 ++ .../providers/oauth2/google.success.json | 21 +++ .../oauth2/google.without.email.json | 11 ++ 6 files changed, 260 insertions(+), 19 deletions(-) create mode 100644 test/com/mohiva/play/silhouette/core/providers/oauth2/GoogleProviderSpec.scala create mode 100644 test/resources/providers/oauth2/google.error.json create mode 100644 test/resources/providers/oauth2/google.success.json create mode 100644 test/resources/providers/oauth2/google.without.email.json diff --git a/app/com/mohiva/play/silhouette/core/providers/oauth2/GoogleProvider.scala b/app/com/mohiva/play/silhouette/core/providers/oauth2/GoogleProvider.scala index 9029a1ea6..670f7e4cf 100644 --- a/app/com/mohiva/play/silhouette/core/providers/oauth2/GoogleProvider.scala +++ b/app/com/mohiva/play/silhouette/core/providers/oauth2/GoogleProvider.scala @@ -36,6 +36,10 @@ import OAuth2Provider._ * @param cacheLayer The cache layer implementation. * @param httpLayer The HTTP layer implementation. * @param settings The provider settings. + * @see https://developers.google.com/+/api/auth-migration#timetable + * @see https://developers.google.com/+/api/auth-migration#oauth2login + * @see https://developers.google.com/accounts/docs/OAuth2Login + * @see https://developers.google.com/+/api/latest/people */ class GoogleProvider( protected val authInfoService: AuthInfoService, @@ -62,17 +66,24 @@ class GoogleProvider( val json = response.json (json \ Error).asOpt[JsObject] match { case Some(error) => - val errorType = (error \ Type).as[String] + val errorCode = (error \ Code).as[Int] val errorMsg = (error \ Message).as[String] - throw new AuthenticationException(SpecifiedProfileError.format(id, errorType, errorMsg)) + throw new AuthenticationException(SpecifiedProfileError.format(id, errorCode, errorMsg)) case _ => val userID = (json \ ID).as[String] - val firstName = (json \ GivenName).asOpt[String] - val lastName = (json \ FamilyName).asOpt[String] - val fullName = (json \ Name).asOpt[String] - val avatarURL = (json \ Picture).asOpt[String] - val email = (json \ Email).asOpt[String] + val firstName = (json \ Name \ GivenName).asOpt[String] + val lastName = (json \ Name \ FamilyName).asOpt[String] + val fullName = (json \ DisplayName).asOpt[String] + val avatarURL = (json \ Image \ URL).asOpt[String] + + // https://developers.google.com/+/api/latest/people#emails.type + val emailIndex = (json \ Emails \\ Type).indexWhere(_.as[String] == Account) + val emailValue = if ((json \ Emails \\ Value).isDefinedAt(emailIndex)) { + (json \ Emails \\ Value)(emailIndex).asOpt[String] + } else { + None + } SocialProfile( loginInfo = LoginInfo(id, userID), @@ -80,9 +91,12 @@ class GoogleProvider( lastName = lastName, fullName = fullName, avatarURL = avatarURL, - email = email) + email = emailValue) } - }.recover { case e => throw new AuthenticationException(UnspecifiedProfileError.format(id), e) } + }.recover { + case e if !e.isInstanceOf[AuthenticationException] => + throw new AuthenticationException(UnspecifiedProfileError.format(id), e) + } } } @@ -95,19 +109,23 @@ object GoogleProvider { * The error messages. */ val UnspecifiedProfileError = "[Silhouette][%s] Error retrieving profile information" - val SpecifiedProfileError = "[Silhouette][%s] Error retrieving profile information. Error type: %s, message: %s" + val SpecifiedProfileError = "[Silhouette][%s] Error retrieving profile information. Error code: %s, message: %s" /** * The Google constants. */ val Google = "google" - val API = "https://www.googleapis.com/oauth2/v1/userinfo?access_token=%s" - val Type = "type" + val API = "https://www.googleapis.com/plus/v1/people/me?access_token=%s" val Message = "message" val ID = "id" val Name = "name" - val GivenName = "given_name" - val FamilyName = "family_name" - val Picture = "picture" - val Email = "email" + val GivenName = "givenName" + val FamilyName = "familyName" + val DisplayName = "displayName" + val Image = "image" + val URL = "url" + val Emails = "emails" + val Value = "value" + val Type = "type" + val Account = "account" } diff --git a/test/com/mohiva/play/silhouette/core/providers/OAuth2ProviderSpec.scala b/test/com/mohiva/play/silhouette/core/providers/OAuth2ProviderSpec.scala index 046d0d9aa..f937413ce 100644 --- a/test/com/mohiva/play/silhouette/core/providers/OAuth2ProviderSpec.scala +++ b/test/com/mohiva/play/silhouette/core/providers/OAuth2ProviderSpec.scala @@ -197,9 +197,9 @@ trait OAuth2ProviderSpecContext extends Scope with Mockito with ThrownExpectatio */ lazy val oAuthInfo: JsValue = Json.obj( AccessToken -> "my.access.token", - TokenType -> "", - ExpiresIn -> 5, - RefreshToken -> "") + TokenType -> "bearer", + ExpiresIn -> 3600, + RefreshToken -> "my.refresh.token") /** * The OAuth2 settings. diff --git a/test/com/mohiva/play/silhouette/core/providers/oauth2/GoogleProviderSpec.scala b/test/com/mohiva/play/silhouette/core/providers/oauth2/GoogleProviderSpec.scala new file mode 100644 index 000000000..25ae0de1b --- /dev/null +++ b/test/com/mohiva/play/silhouette/core/providers/oauth2/GoogleProviderSpec.scala @@ -0,0 +1,176 @@ +/** + * Copyright 2014 Mohiva Organisation (license at mohiva dot com) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.mohiva.play.silhouette.core.providers.oauth2 + +import test.Helper +import java.util.UUID +import play.api.libs.ws.{ Response, WS } +import play.api.test.{ FakeRequest, WithApplication } +import scala.concurrent.Future +import com.mohiva.play.silhouette.core.providers._ +import com.mohiva.play.silhouette.core.{ LoginInfo, AuthenticationException } +import GoogleProvider._ +import OAuth2Provider._ +import play.api.libs.json.Json + +/** + * Test case for the [[com.mohiva.play.silhouette.core.providers.oauth2.GoogleProvider]] class. + */ +class GoogleProviderSpec extends OAuth2ProviderSpec { + + "The authenticate method" should { + "throw AuthenticationException if OAuth2Info can be build because of an unexpected response" in new WithApplication with Context { + val cacheID = UUID.randomUUID().toString + val state = UUID.randomUUID().toString + val requestHolder = mock[WS.WSRequestHolder] + val response = mock[Response] + implicit val req = FakeRequest(GET, "?" + Code + "=my.code&" + State + "=" + state).withSession(CacheKey -> cacheID) + response.json returns Json.obj() + requestHolder.withHeaders(any) returns requestHolder + requestHolder.post[Map[String, Seq[String]]](any)(any, any) returns Future.successful(response) + cacheLayer.get[String](cacheID) returns Future.successful(Some(state)) + httpLayer.url(oAuthSettings.accessTokenURL) returns requestHolder + + await(provider.authenticate()) must throwAn[AuthenticationException].like { + case e => e.getMessage must startWith(InvalidResponseFormat.format(provider.id, "")) + } + } + + "throw AuthenticationException if API returns error" in new WithApplication with Context { + val cacheID = UUID.randomUUID().toString + val state = UUID.randomUUID().toString + val requestHolder = mock[WS.WSRequestHolder] + val response = mock[Response] + implicit val req = FakeRequest(GET, "?" + Code + "=my.code&" + State + "=" + state).withSession(CacheKey -> cacheID) + response.json returns oAuthInfo thenReturns Helper.loadJson("providers/oauth2/google.error.json") + requestHolder.withHeaders(any) returns requestHolder + requestHolder.post[Map[String, Seq[String]]](any)(any, any) returns Future.successful(response) + requestHolder.get() returns Future.successful(response) + cacheLayer.get[String](cacheID) returns Future.successful(Some(state)) + httpLayer.url(oAuthSettings.accessTokenURL) returns requestHolder + httpLayer.url(API.format("my.access.token")) returns requestHolder + + await(provider.authenticate()) must throwAn[AuthenticationException].like { + case e => e.getMessage must equalTo(SpecifiedProfileError.format( + provider.id, + 401, + "Invalid Credentials")) + } + } + + "throw AuthenticationException if an unexpected error occurred" in new WithApplication with Context { + val cacheID = UUID.randomUUID().toString + val state = UUID.randomUUID().toString + val requestHolder = mock[WS.WSRequestHolder] + val response = mock[Response] + implicit val req = FakeRequest(GET, "?" + Code + "=my.code&" + State + "=" + state).withSession(CacheKey -> cacheID) + response.json returns oAuthInfo thenThrows new RuntimeException("") + requestHolder.withHeaders(any) returns requestHolder + requestHolder.post[Map[String, Seq[String]]](any)(any, any) returns Future.successful(response) + requestHolder.get() returns Future.successful(response) + cacheLayer.get[String](cacheID) returns Future.successful(Some(state)) + httpLayer.url(oAuthSettings.accessTokenURL) returns requestHolder + httpLayer.url(API.format("my.access.token")) returns requestHolder + + await(provider.authenticate()) must throwAn[AuthenticationException].like { + case e => e.getMessage must equalTo(UnspecifiedProfileError.format(provider.id)) + } + } + + "return the social profile with an email" in new WithApplication with Context { + val cacheID = UUID.randomUUID().toString + val state = UUID.randomUUID().toString + val requestHolder = mock[WS.WSRequestHolder] + val response = mock[Response] + implicit val req = FakeRequest(GET, "?" + Code + "=my.code&" + State + "=" + state).withSession(CacheKey -> cacheID) + response.json returns oAuthInfo thenReturns Helper.loadJson("providers/oauth2/google.success.json") + requestHolder.withHeaders(any) returns requestHolder + requestHolder.post[Map[String, Seq[String]]](any)(any, any) returns Future.successful(response) + requestHolder.get() returns Future.successful(response) + cacheLayer.get[String](cacheID) returns Future.successful(Some(state)) + httpLayer.url(oAuthSettings.accessTokenURL) returns requestHolder + httpLayer.url(API.format("my.access.token")) returns requestHolder + + await(provider.authenticate()) must beRight.like { + case p => + p must be equalTo new SocialProfile( + loginInfo = LoginInfo(provider.id, "109476598527568979481"), + firstName = Some("Apollonia"), + lastName = Some("Vanova"), + fullName = Some("Apollonia Vanova"), + email = Some("apollonia.vanova@watchmen.com"), + avatarURL = Some("https://lh6.googleusercontent.com/-m34A6I77dJU/ASASAASADAAI/AVABAAAAAJk/5cg1hcjo_4s/photo.jpg?sz=50") + ) + } + } + + "return the social profile without an email" in new WithApplication with Context { + val cacheID = UUID.randomUUID().toString + val state = UUID.randomUUID().toString + val requestHolder = mock[WS.WSRequestHolder] + val response = mock[Response] + implicit val req = FakeRequest(GET, "?" + Code + "=my.code&" + State + "=" + state).withSession(CacheKey -> cacheID) + response.json returns oAuthInfo thenReturns Helper.loadJson("providers/oauth2/google.without.email.json") + requestHolder.withHeaders(any) returns requestHolder + requestHolder.post[Map[String, Seq[String]]](any)(any, any) returns Future.successful(response) + requestHolder.get() returns Future.successful(response) + cacheLayer.get[String](cacheID) returns Future.successful(Some(state)) + httpLayer.url(oAuthSettings.accessTokenURL) returns requestHolder + httpLayer.url(API.format("my.access.token")) returns requestHolder + + await(provider.authenticate()) must beRight.like { + case p => + p must be equalTo new SocialProfile( + loginInfo = LoginInfo(provider.id, "109476598527568979481"), + firstName = Some("Apollonia"), + lastName = Some("Vanova"), + fullName = Some("Apollonia Vanova"), + email = None, + avatarURL = Some("https://lh6.googleusercontent.com/-m34A6I77dJU/ASASAASADAAI/AVABAAAAAJk/5cg1hcjo_4s/photo.jpg?sz=50") + ) + } + } + } + + /** + * Defines the context for the abstract OAuth2 provider spec. + * + * @return The Context to use for the abstract OAuth2 provider spec. + */ + override protected def context: OAuth2ProviderSpecContext = new Context {} + + /** + * The context. + */ + trait Context extends OAuth2ProviderSpecContext { + + /** + * The OAuth2 settings. + */ + lazy val oAuthSettings = OAuth2Settings( + authorizationURL = "https://accounts.google.com/o/oauth2/auth", + accessTokenURL = "https://accounts.google.com/o/oauth2/token", + redirectURL = "https://www.mohiva.com", + clientID = "my.client.id", + clientSecret = "my.client.secret", + scope = Some("profile,email")) + + /** + * The provider to test. + */ + lazy val provider = new GoogleProvider(authInfoService, cacheLayer, httpLayer, oAuthSettings) + } +} diff --git a/test/resources/providers/oauth2/google.error.json b/test/resources/providers/oauth2/google.error.json new file mode 100644 index 000000000..fbcf8592e --- /dev/null +++ b/test/resources/providers/oauth2/google.error.json @@ -0,0 +1,15 @@ +{ + "error": { + "errors": [ + { + "domain": "global", + "reason": "authError", + "message": "Invalid Credentials", + "locationType": "header", + "location": "Authorization" + } + ], + "code": 401, + "message": "Invalid Credentials" + } +} diff --git a/test/resources/providers/oauth2/google.success.json b/test/resources/providers/oauth2/google.success.json new file mode 100644 index 000000000..6b1762297 --- /dev/null +++ b/test/resources/providers/oauth2/google.success.json @@ -0,0 +1,21 @@ +{ + "emails": [ + { + "value": "home@watchmen.com", + "type": "home" + }, + { + "value": "apollonia.vanova@watchmen.com", + "type": "account" + } + ], + "id": "109476598527568979481", + "displayName": "Apollonia Vanova", + "name": { + "familyName": "Vanova", + "givenName": "Apollonia" + }, + "image": { + "url": "https://lh6.googleusercontent.com/-m34A6I77dJU/ASASAASADAAI/AVABAAAAAJk/5cg1hcjo_4s/photo.jpg?sz=50" + } +} diff --git a/test/resources/providers/oauth2/google.without.email.json b/test/resources/providers/oauth2/google.without.email.json new file mode 100644 index 000000000..96038a3f5 --- /dev/null +++ b/test/resources/providers/oauth2/google.without.email.json @@ -0,0 +1,11 @@ +{ + "id": "109476598527568979481", + "displayName": "Apollonia Vanova", + "name": { + "familyName": "Vanova", + "givenName": "Apollonia" + }, + "image": { + "url": "https://lh6.googleusercontent.com/-m34A6I77dJU/ASASAASADAAI/AVABAAAAAJk/5cg1hcjo_4s/photo.jpg?sz=50" + } +}