Extract interface / types to allow unit tests / mock

This commit is contained in:
Guillaume Tardif 2020-05-12 17:26:11 +02:00
parent 1e19d977e0
commit 69f10fe80c
8 changed files with 278 additions and 118 deletions

View File

@ -2,7 +2,7 @@ linters:
run:
concurrency: 2
skip-dirs:
- composefiles
- tests/composefiles
enable-all: false
disable-all: true
enable:

View File

@ -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()
}

View File

@ -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(`
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta http-equiv="refresh" content="10;url=https://docs.microsoft.com/cli/azure/">
<title>Login successfully</title>
</head>
<body>
<h4>You have logged into Microsoft Azure!</h4>
<p>You can close this window, or we will redirect you to the <a href="https://docs.microsoft.com/cli/azure/">Azure CLI documents</a> in 10 seconds.</p>
</body>
</html>
`))
} else {
w.Write([]byte(`
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Login failed</title>
</head>
<body>
<h4>Some failures occurred during the authentication</h4>
<p>You can log an issue at <a href="https://github.com/azure/azure-cli/issues">Azure CLI GitHub Repository</a> and we will assist you in resolving it.</p>
</body>
</html>
`))
}
}
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 = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Login failed</title>
</head>
<body>
<h4>Some failures occurred during the authentication</h4>
<p>You can log an issue at <a href="https://github.com/azure/azure-cli/issues">Azure CLI GitHub Repository</a> and we will assist you in resolving it.</p>
</body>
</html>
`
const successfullLoginHTML = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta http-equiv="refresh" content="10;url=https://docs.microsoft.com/cli/azure/">
<title>Login successfully</title>
</head>
<body>
<h4>You have logged into Microsoft Azure!</h4>
<p>You can close this window, or we will redirect you to the <a href="https://docs.microsoft.com/cli/azure/">Azure CLI documents</a> in 10 seconds.</p>
</body>
</html>
`

113
azure/login/login_test.go Normal file
View File

@ -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)
}

View File

@ -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

View File

@ -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"
)

View File

@ -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

4
go.sum
View File

@ -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=