From 69f10fe80ce0e0d7a97944aaa2d002f43d8ba402 Mon Sep 17 00:00:00 2001 From: Guillaume Tardif Date: Tue, 12 May 2020 17:26:11 +0200 Subject: [PATCH] Extract interface / types to allow unit tests / mock --- .golangci.yml | 2 +- azure/backend.go | 22 ++-- azure/login/login.go | 234 ++++++++++++++++++++------------- azure/login/login_test.go | 113 ++++++++++++++++ azure/login/tokenStore.go | 18 +-- cli/cmd/context/login/login.go | 1 + errdefs/errors.go | 2 + go.sum | 4 + 8 files changed, 278 insertions(+), 118 deletions(-) create mode 100644 azure/login/login_test.go diff --git a/.golangci.yml b/.golangci.yml index cf8a7151..1267dab9 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -2,7 +2,7 @@ linters: run: concurrency: 2 skip-dirs: - - composefiles + - tests/composefiles enable-all: false disable-all: true enable: diff --git a/azure/backend.go b/azure/backend.go index 17337823..14cb8189 100644 --- a/azure/backend.go +++ b/azure/backend.go @@ -3,12 +3,13 @@ package azure import ( "context" "fmt" - "github.com/docker/api/context/cloud" "io" "net/http" "strconv" "strings" + "github.com/docker/api/context/cloud" + "github.com/Azure/azure-sdk-for-go/services/containerinstance/mgmt/2018-10-01/containerinstance" "github.com/Azure/go-autorest/autorest/azure/auth" "github.com/compose-spec/compose-go/types" @@ -69,7 +70,9 @@ func getAciAPIService(cgc containerinstance.ContainerGroupsClient, aciCtx store. containerGroupsClient: cgc, ctx: aciCtx, }, - aciCloudService: aciCloudService{}, + aciCloudService: aciCloudService{ + loginService: login.NewAzureLoginService(), + }, } } @@ -80,21 +83,15 @@ type aciAPIService struct { } func (a *aciAPIService) ContainerService() containers.Service { - return &aciContainerService{ - containerGroupsClient: a.aciContainerService.containerGroupsClient, - ctx: a.aciContainerService.ctx, - } + return &a.aciContainerService } func (a *aciAPIService) ComposeService() compose.Service { - return &aciComposeService{ - containerGroupsClient: a.aciComposeService.containerGroupsClient, - ctx: a.aciComposeService.ctx, - } + return &a.aciComposeService } func (a *aciAPIService) CloudService() cloud.Service { - return &aciCloudService{} + return &a.aciCloudService } type aciContainerService struct { @@ -276,8 +273,9 @@ func (cs *aciComposeService) Down(ctx context.Context, opts compose.ProjectOptio } type aciCloudService struct { + loginService login.AzureLoginService } func (cs *aciCloudService) Login(ctx context.Context, params map[string]string) error { - return login.Login() + return cs.loginService.Login() } diff --git a/azure/login/login.go b/azure/login/login.go index eac0bc91..f671574d 100644 --- a/azure/login/login.go +++ b/azure/login/login.go @@ -19,6 +19,8 @@ import ( "syscall" "time" + "github.com/docker/api/errdefs" + "github.com/Azure/go-autorest/autorest" "github.com/Azure/go-autorest/autorest/adal" "github.com/Azure/go-autorest/autorest/azure/cli" @@ -28,6 +30,10 @@ import ( "github.com/pkg/errors" ) +func init() { + rand.Seed(time.Now().Unix()) +} + //go login process, derived from code sample provided by MS at https://github.com/devigned/go-az-cli-stuff const ( authorizeFormat = "https://login.microsoftonline.com/organizations/oauth2/v2.0/authorize?response_type=code&client_id=%s&redirect_uri=%s&state=%s&prompt=select_account&response_mode=query&scope=%s" @@ -39,7 +45,7 @@ const ( ) type ( - Token struct { + azureToken struct { Type string `json:"token_type"` Scope string `json:"scope"` ExpiresIn int `json:"expires_in"` @@ -49,67 +55,65 @@ type ( Foci string `json:"foci"` } - TenantResult struct { - Value []TenantValue `json:"value"` + tenantResult struct { + Value []tenantValue `json:"value"` } - TenantValue struct { + tenantValue struct { TenantID string `json:"tenantId"` } ) -//AzureLogin login through browser -func Login() error { +// AzureLoginService Service to log into azure and get authentifier for azure APIs +type AzureLoginService struct { + tokenStore tokenStore + apiHelper apiHelper +} + +const tokenFilename = "dockerAccessToken.json" + +func getTokenStorePath() string { + cliPath, _ := cli.AccessTokensPath() + return filepath.Join(filepath.Dir(cliPath), tokenFilename) +} + +// NewAzureLoginService creates a NewAzureLoginService +func NewAzureLoginService() AzureLoginService { + return newAzureLoginServiceFromPath(getTokenStorePath(), azureAPIHelper{}) +} + +func newAzureLoginServiceFromPath(tokenStorePath string, helper apiHelper) AzureLoginService { + return AzureLoginService{ + tokenStore: tokenStore{ + filePath: tokenStorePath, + }, + apiHelper: helper, + } +} + +type apiHelper interface { + queryToken(data url.Values, tenantID string) (token azureToken, err error) +} + +type azureAPIHelper struct{} + +//Login perform azure login through browser +func (login AzureLoginService) Login() error { sigs := make(chan os.Signal, 1) signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) queryCh := make(chan url.Values, 1) - queryHandler := func(w http.ResponseWriter, r *http.Request) { - queryCh <- r.URL.Query() - _, hasCode := r.URL.Query()["code"] - if hasCode { - w.Write([]byte(` - - - - - - Login successfully - - -

You have logged into Microsoft Azure!

-

You can close this window, or we will redirect you to the Azure CLI documents in 10 seconds.

- - - `)) - } else { - w.Write([]byte(` - - - - - Login failed - - -

Some failures occurred during the authentication

-

You can log an issue at Azure CLI GitHub Repository and we will assist you in resolving it.

- - -`)) - } - } - mux := http.NewServeMux() - mux.HandleFunc("/", queryHandler) + mux.HandleFunc("/", queryHandler(queryCh)) server := &http.Server{Addr: ":8401", Handler: mux} go func() { if err := server.ListenAndServe(); err != nil { - fmt.Println(fmt.Errorf("error starting http server with: %w", err)) - os.Exit(1) + queryCh <- url.Values{ + "error": []string{fmt.Sprintf("error starting http server with: %v", err)}, + } } }() - state := RandomString("", 10) - //nonce := RandomString("", 10) + state := randomString("", 10) authURL := fmt.Sprintf(authorizeFormat, clientID, "http://localhost:8401", state, scopes) openbrowser(authURL) @@ -117,9 +121,13 @@ func Login() error { case <-sigs: return nil case qsValues := <-queryCh: + errorMsg, hasError := qsValues["error"] + if hasError { + return fmt.Errorf("login failed : %s", errorMsg) + } code, hasCode := qsValues["code"] if !hasCode { - return fmt.Errorf("Authentication Error : Login failed") + return errdefs.ErrLoginFailed } data := url.Values{ "grant_type": []string{"authorization_code"}, @@ -128,7 +136,7 @@ func Login() error { "scope": []string{scopes}, "redirect_uri": []string{"http://localhost:8401"}, } - token, err := queryToken(data, "organizations") + token, err := login.apiHelper.queryToken(data, "organizations") if err != nil { return errors.Wrap(err, "Access token request failed") } @@ -141,53 +149,78 @@ func Login() error { req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token.AccessToken)) res, err := http.DefaultClient.Do(req) if err != nil { - return errors.Wrap(err, "Authentication Error") + return errors.Wrap(err, "login failed") } bits, err := ioutil.ReadAll(res.Body) if err != nil { - return errors.Wrap(err, "Authentication Error") + return errors.Wrap(err, "login failed") } if res.StatusCode == 200 { - var tenantResult TenantResult + var tenantResult tenantResult if err := json.Unmarshal(bits, &tenantResult); err != nil { - return errors.Wrap(err, "Authentication Error") + return errors.Wrap(err, "login failed") } tenantID := tenantResult.Value[0].TenantID - tenantToken, err := refreshToken(token.RefreshToken, tenantID) + tenantToken, err := login.refreshToken(token.RefreshToken, tenantID) if err != nil { - return errors.Wrap(err, "Authentication Error") + return errors.Wrap(err, "login failed") } - loginInfo := LoginInfo{TenantID: tenantID, Token: tenantToken} + loginInfo := TokenInfo{TenantID: tenantID, Token: tenantToken} - store := NewTokenStore(getTokenPath()) - err = store.writeLoginInfo(loginInfo) + err = login.tokenStore.writeLoginInfo(loginInfo) if err != nil { - return errors.Wrap(err, "Authentication Error") + return errors.Wrap(err, "login failed") } - fmt.Println("Successfully logged in") + fmt.Println("Login Succeeded") return nil } bits, err = httputil.DumpResponse(res, true) if err != nil { - return errors.Wrap(err, "Authentication Error") + return errors.Wrap(err, "login failed") } - return fmt.Errorf("Authentication Error: \n" + string(bits)) + return fmt.Errorf("login failed: \n" + string(bits)) } } -func queryToken(data url.Values, tenantID string) (token Token, err error) { +func queryHandler(queryCh chan url.Values) func(w http.ResponseWriter, r *http.Request) { + queryHandler := func(w http.ResponseWriter, r *http.Request) { + _, hasCode := r.URL.Query()["code"] + if hasCode { + _, err := w.Write([]byte(successfullLoginHTML)) + if err != nil { + queryCh <- url.Values{ + "error": []string{err.Error()}, + } + } else { + queryCh <- r.URL.Query() + } + } else { + _, err := w.Write([]byte(loginFailedHTML)) + if err != nil { + queryCh <- url.Values{ + "error": []string{err.Error()}, + } + } else { + queryCh <- r.URL.Query() + } + } + } + return queryHandler +} + +func (helper azureAPIHelper) queryToken(data url.Values, tenantID string) (token azureToken, err error) { res, err := http.Post(fmt.Sprintf(tokenEndpoint, tenantID), "application/x-www-form-urlencoded", strings.NewReader(data.Encode())) if err != nil { return token, err } if res.StatusCode != 200 { - return token, err + return token, errors.Errorf("error while renewing access token, status : %s", res.Status) } bits, err := ioutil.ReadAll(res.Body) if err != nil { @@ -199,8 +232,8 @@ func queryToken(data url.Values, tenantID string) (token Token, err error) { return token, nil } -func toOAuthToken(token Token) oauth2.Token { - expireTime := time.Now().Add(time.Duration(token.ExtExpiresIn) * time.Second) +func toOAuthToken(token azureToken) oauth2.Token { + expireTime := time.Now().Add(time.Duration(token.ExpiresIn) * time.Second) oauthToken := oauth2.Token{ RefreshToken: token.RefreshToken, AccessToken: token.AccessToken, @@ -210,27 +243,18 @@ func toOAuthToken(token Token) oauth2.Token { return oauthToken } -const tokenFilename = "dockerAccessToken.json" - -func getTokenPath() string { - cliPath, _ := cli.AccessTokensPath() - - return filepath.Join(filepath.Dir(cliPath), tokenFilename) -} - -func NewAuthorizerFromLogin() (autorest.Authorizer, error) { - oauthToken, err := GetValidToken() +// NewAuthorizerFromLogin creates an authorizer based on login access token +func (login AzureLoginService) NewAuthorizerFromLogin() (autorest.Authorizer, error) { + oauthToken, err := login.GetValidToken() if err != nil { return nil, err } - difference := oauthToken.Expiry.Sub(date.UnixEpoch()) - token := adal.Token{ AccessToken: oauthToken.AccessToken, Type: oauthToken.TokenType, - ExpiresIn: "3600", - ExpiresOn: json.Number(strconv.Itoa(int(difference.Seconds()))), + ExpiresIn: json.Number(strconv.Itoa(int(oauthToken.Expiry.Sub(time.Now()).Seconds()))), + ExpiresOn: json.Number(strconv.Itoa(int(oauthToken.Expiry.Sub(date.UnixEpoch()).Seconds()))), RefreshToken: "", Resource: "", } @@ -238,9 +262,9 @@ func NewAuthorizerFromLogin() (autorest.Authorizer, error) { return autorest.NewBearerAuthorizer(&token), nil } -func GetValidToken() (token oauth2.Token, err error) { - store := NewTokenStore(getTokenPath()) - loginInfo, err := store.readToken() +// GetValidToken returns an access token. Refresh token if needed +func (login AzureLoginService) GetValidToken() (token oauth2.Token, err error) { + loginInfo, err := login.tokenStore.readToken() if err != nil { return token, err } @@ -249,25 +273,25 @@ func GetValidToken() (token oauth2.Token, err error) { return token, nil } tenantID := loginInfo.TenantID - token, err = refreshToken(token.RefreshToken, tenantID) + token, err = login.refreshToken(token.RefreshToken, tenantID) if err != nil { - return token, errors.Wrap(err, "Access token request failed. Maybe you need to login to azure again.") + return token, errors.Wrap(err, "access token request failed. Maybe you need to login to azure again.") } - err = store.writeLoginInfo(LoginInfo{TenantID: tenantID, Token: token}) + err = login.tokenStore.writeLoginInfo(TokenInfo{TenantID: tenantID, Token: token}) if err != nil { return token, err } return token, nil } -func refreshToken(currentRefreshToken string, tenantID string) (oauthToken oauth2.Token, err error) { +func (login AzureLoginService) refreshToken(currentRefreshToken string, tenantID string) (oauthToken oauth2.Token, err error) { data := url.Values{ "grant_type": []string{"refresh_token"}, "client_id": []string{clientID}, "scope": []string{scopes}, "refresh_token": []string{currentRefreshToken}, } - token, err := queryToken(data, tenantID) + token, err := login.apiHelper.queryToken(data, tenantID) if err != nil { return oauthToken, err } @@ -297,15 +321,39 @@ var ( letterRunes = []rune("abcdefghijklmnopqrstuvwxyz123456789") ) -func init() { - rand.Seed(time.Now().Unix()) -} - -// RandomString generates a random string with prefix -func RandomString(prefix string, length int) string { +func randomString(prefix string, length int) string { b := make([]rune, length) for i := range b { b[i] = letterRunes[rand.Intn(len(letterRunes))] } return prefix + string(b) } + +const loginFailedHTML = ` + + + + + Login failed + + +

Some failures occurred during the authentication

+

You can log an issue at Azure CLI GitHub Repository and we will assist you in resolving it.

+ + + ` + +const successfullLoginHTML = ` + + + + + + Login successfully + + +

You have logged into Microsoft Azure!

+

You can close this window, or we will redirect you to the Azure CLI documents in 10 seconds.

+ + + ` diff --git a/azure/login/login_test.go b/azure/login/login_test.go new file mode 100644 index 00000000..0295d505 --- /dev/null +++ b/azure/login/login_test.go @@ -0,0 +1,113 @@ +package login + +import ( + "io/ioutil" + "net/url" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" + + "golang.org/x/oauth2" + + . "github.com/onsi/gomega" +) + +type LoginSuiteTest struct { + suite.Suite + dir string + mockHelper MockAzureHelper + azureLogin AzureLoginService +} + +func (suite *LoginSuiteTest) BeforeTest(suiteName, testName string) { + dir, err := ioutil.TempDir("", "test_store") + require.Nil(suite.T(), err) + + suite.dir = dir + suite.mockHelper = MockAzureHelper{} + //nolint copylocks + suite.azureLogin = newAzureLoginServiceFromPath(filepath.Join(dir, tokenFilename), suite.mockHelper) +} + +func (suite *LoginSuiteTest) AfterTest(suiteName, testName string) { + err := os.RemoveAll(suite.dir) + require.Nil(suite.T(), err) +} + +func (suite *LoginSuiteTest) TestRefreshInValidToken() { + data := url.Values{ + "grant_type": []string{"refresh_token"}, + "client_id": []string{clientID}, + "scope": []string{scopes}, + "refresh_token": []string{"refreshToken"}, + } + suite.mockHelper.On("queryToken", data, "123456").Return(azureToken{ + RefreshToken: "newRefreshToken", + AccessToken: "newAccessToken", + ExpiresIn: 3600, + Foci: "1", + }, nil) + + //nolint copylocks + suite.azureLogin = newAzureLoginServiceFromPath(filepath.Join(suite.dir, tokenFilename), suite.mockHelper) + err := suite.azureLogin.tokenStore.writeLoginInfo(TokenInfo{ + TenantID: "123456", + Token: oauth2.Token{ + AccessToken: "accessToken", + RefreshToken: "refreshToken", + Expiry: time.Now().Add(-1 * time.Hour), + TokenType: "Bearer", + }, + }) + Expect(err).To(BeNil()) + + token, _ := suite.azureLogin.GetValidToken() + + Expect(token.AccessToken).To(Equal("newAccessToken")) + Expect(token.Expiry).To(BeTemporally(">", time.Now().Add(3500*time.Second))) + + storedToken, _ := suite.azureLogin.tokenStore.readToken() + Expect(storedToken.Token.AccessToken).To(Equal("newAccessToken")) + Expect(storedToken.Token.RefreshToken).To(Equal("newRefreshToken")) + Expect(storedToken.Token.Expiry).To(BeTemporally(">", time.Now().Add(3500*time.Second))) +} + +func (suite *LoginSuiteTest) TestDoesNotRefreshValidToken() { + expiryDate := time.Now().Add(1 * time.Hour) + err := suite.azureLogin.tokenStore.writeLoginInfo(TokenInfo{ + TenantID: "123456", + Token: oauth2.Token{ + AccessToken: "accessToken", + RefreshToken: "refreshToken", + Expiry: expiryDate, + TokenType: "Bearer", + }, + }) + Expect(err).To(BeNil()) + + token, _ := suite.azureLogin.GetValidToken() + + Expect(suite.mockHelper.Calls).To(BeEmpty()) + Expect(token.AccessToken).To(Equal("accessToken")) +} + +func TestLoginSuite(t *testing.T) { + RegisterTestingT(t) + suite.Run(t, new(LoginSuiteTest)) +} + +type MockAzureHelper struct { + mock.Mock +} + +//nolint copylocks +func (s MockAzureHelper) queryToken(data url.Values, tenantID string) (token azureToken, err error) { + args := s.Called(data, tenantID) + return args.Get(0).(azureToken), args.Error(1) +} diff --git a/azure/login/tokenStore.go b/azure/login/tokenStore.go index 3cefdda9..8e0b7dac 100644 --- a/azure/login/tokenStore.go +++ b/azure/login/tokenStore.go @@ -7,31 +7,25 @@ import ( "golang.org/x/oauth2" ) -type TokenStore struct { +type tokenStore struct { filePath string } -type LoginInfo struct { +// TokenInfo data stored in tokenStore +type TokenInfo struct { Token oauth2.Token `json:"oauthToken"` TenantID string `json:"tenantId"` } -func NewTokenStore(filePath string) TokenStore { - return TokenStore{ - filePath: filePath, - } -} - -func (store TokenStore) writeLoginInfo(info LoginInfo) error { +func (store tokenStore) writeLoginInfo(info TokenInfo) error { bytes, err := json.MarshalIndent(info, "", " ") if err != nil { return err } - ioutil.WriteFile(store.filePath, bytes, 0644) - return nil + return ioutil.WriteFile(store.filePath, bytes, 0644) } -func (store TokenStore) readToken() (loginInfo LoginInfo, err error) { +func (store tokenStore) readToken() (loginInfo TokenInfo, err error) { bytes, err := ioutil.ReadFile(store.filePath) if err != nil { return loginInfo, err diff --git a/cli/cmd/context/login/login.go b/cli/cmd/context/login/login.go index 4e113834..7ab0d51b 100644 --- a/cli/cmd/context/login/login.go +++ b/cli/cmd/context/login/login.go @@ -3,6 +3,7 @@ package login import ( "github.com/spf13/cobra" "github.com/pkg/errors" + "github.com/docker/api/client" apicontext "github.com/docker/api/context" ) diff --git a/errdefs/errors.go b/errdefs/errors.go index 1bd21d9f..e01ac4ef 100644 --- a/errdefs/errors.go +++ b/errdefs/errors.go @@ -40,6 +40,8 @@ var ( ErrForbidden = errors.New("forbidden") // ErrUnknown is returned when the error type is unmapped ErrUnknown = errors.New("unknown") + // ErrLoginFailed is returned when login failed + ErrLoginFailed = errors.New("login failed") ) // IsNotFoundError returns true if the unwrapped error is ErrNotFound diff --git a/go.sum b/go.sum index e44b75c6..84dad30e 100644 --- a/go.sum +++ b/go.sum @@ -3,10 +3,12 @@ github.com/Azure/azure-sdk-for-go v42.0.0+incompatible h1:yz6sFf5bHZ+gEOQVuK5JhP github.com/Azure/azure-sdk-for-go v42.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78 h1:w+iIsaOQNcT7OZ575w+acHgRric5iCyQh+xv+KJ4HB8= github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8= +github.com/Azure/go-autorest v14.1.0+incompatible h1:qROrS0rWxAXGfFdNOI33we8553d7T8v78jXf/8tjLBM= github.com/Azure/go-autorest/autorest v0.9.0/go.mod h1:xyHB1BMZT0cuDHU7I0+g046+BFDTQ8rEZB0s4Yfa6bI= github.com/Azure/go-autorest/autorest v0.9.3/go.mod h1:GsRuLYvwzLjjjRoWEIyMUaYq8GNUx2nRB378IPt/1p0= github.com/Azure/go-autorest/autorest v0.10.0 h1:mvdtztBqcL8se7MdrUweNieTNi4kfNG6GOJuurQJpuY= github.com/Azure/go-autorest/autorest v0.10.0/go.mod h1:/FALq9T/kS7b5J5qsQ+RSTUdAmGFqi0vUdVNNx8q630= +github.com/Azure/go-autorest/autorest v0.10.1 h1:uaB8A32IZU9YKs9v50+/LWIWTDHJk2vlGzbfd7FfESI= github.com/Azure/go-autorest/autorest/adal v0.5.0/go.mod h1:8Z9fGy2MpX0PvDjB1pEgQTmVqjGhiHBW7RJJEciWzS0= github.com/Azure/go-autorest/autorest/adal v0.8.0/go.mod h1:Z6vX6WXXuyieHAXwMj0S6HY6e6wcHn37qQMBQlvY3lc= github.com/Azure/go-autorest/autorest/adal v0.8.1/go.mod h1:ZjhuQClTqx435SRJ2iMlOxPYt3d2C/T/7TiQCVZSn3Q= @@ -235,6 +237,7 @@ github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1 h1:2vfRuCMp5sSVIDSqO8oNnWJq7mPa6KVP3iPIwFBuy8A= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= @@ -311,6 +314,7 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55 h1:gSJIx1SDwno+2ElGhA4+qG2zF97qiUzTM+rQ0klBOcE=