Add unit tests for login process

This commit is contained in:
Guillaume Tardif 2020-05-14 22:54:57 +02:00
parent 146dd3e639
commit 7edc6659a2
8 changed files with 173 additions and 40 deletions

View File

@ -9,7 +9,6 @@ import (
"math/rand"
"net"
"net/http"
"net/http/httputil"
"net/url"
"os/exec"
"path/filepath"
@ -37,6 +36,7 @@ func init() {
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"
tokenEndpoint = "https://login.microsoftonline.com/%s/oauth2/v2.0/token"
authorizationURL = "https://management.azure.com/tenants?api-version=2019-11-01"
// scopes for a multi-tenant app works for openid, email, other common scopes, but fails when trying to add a token
// v1 scope like "https://management.azure.com/.default" for ARM access
scopes = "offline_access https://management.azure.com/.default"
@ -93,6 +93,8 @@ func newAzureLoginServiceFromPath(tokenStorePath string, helper apiHelper) (Azur
type apiHelper interface {
queryToken(data url.Values, tenantID string) (azureToken, error)
openAzureLoginPage(redirectURL string)
queryAuthorizationAPI(authorizationURL string, authorizationHeader string) ([]byte, int, error)
}
type azureAPIHelper struct{}
@ -106,7 +108,7 @@ func (login AzureLoginService) Login(ctx context.Context) error {
}
redirectURL := "http://localhost:" + strconv.Itoa(serverPort)
openAzureLoginPage(redirectURL)
login.apiHelper.openAzureLoginPage(redirectURL)
select {
case <-ctx.Done():
@ -132,23 +134,12 @@ func (login AzureLoginService) Login(ctx context.Context) error {
return errors.Wrap(err, "Access token request failed")
}
req, err := http.NewRequest(http.MethodGet, "https://management.azure.com/tenants?api-version=2019-11-01", nil)
if err != nil {
return err
}
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token.AccessToken))
res, err := http.DefaultClient.Do(req)
bits, statusCode, err := login.apiHelper.queryAuthorizationAPI(authorizationURL, fmt.Sprintf("Bearer %s", token.AccessToken))
if err != nil {
return errors.Wrap(err, "login failed")
}
bits, err := ioutil.ReadAll(res.Body)
if err != nil {
return errors.Wrap(err, "login failed")
}
if res.StatusCode == 200 {
if statusCode == 200 {
var tenantResult tenantResult
if err := json.Unmarshal(bits, &tenantResult); err != nil {
return errors.Wrap(err, "login failed")
@ -170,12 +161,7 @@ func (login AzureLoginService) Login(ctx context.Context) error {
return nil
}
bits, err = httputil.DumpResponse(res, true)
if err != nil {
return errors.Wrap(err, "login failed")
}
return fmt.Errorf("login failed: \n" + string(bits))
return fmt.Errorf("login failed : " + string(bits))
}
}
@ -199,12 +185,29 @@ func startLoginServer(queryCh chan url.Values) (int, error) {
return availablePort, nil
}
func openAzureLoginPage(redirectURL string) {
func (helper azureAPIHelper) openAzureLoginPage(redirectURL string) {
state := randomString("", 10)
authURL := fmt.Sprintf(authorizeFormat, clientID, redirectURL, state, scopes)
openbrowser(authURL)
}
func (helper azureAPIHelper) queryAuthorizationAPI(authorizationURL string, authorizationHeader string) ([]byte, int, error) {
req, err := http.NewRequest(http.MethodGet, authorizationURL, nil)
if err != nil {
return nil, 0, err
}
req.Header.Add("Authorization", authorizationHeader)
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, 0, err
}
bits, err := ioutil.ReadAll(res.Body)
if err != nil {
return nil, 0, err
}
return bits, res.StatusCode, nil
}
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"]

View File

@ -1,10 +1,14 @@
package login
import (
"context"
"errors"
"io/ioutil"
"net/http"
"net/url"
"os"
"path/filepath"
"reflect"
"testing"
"time"
@ -19,7 +23,7 @@ import (
type LoginSuiteTest struct {
suite.Suite
dir string
mockHelper MockAzureHelper
mockHelper *MockAzureHelper
azureLogin AzureLoginService
}
@ -28,8 +32,7 @@ func (suite *LoginSuiteTest) BeforeTest(suiteName, testName string) {
Expect(err).To(BeNil())
suite.dir = dir
suite.mockHelper = MockAzureHelper{}
//nolint copylocks
suite.mockHelper = &MockAzureHelper{}
suite.azureLogin, err = newAzureLoginServiceFromPath(filepath.Join(dir, tokenStoreFilename), suite.mockHelper)
Expect(err).To(BeNil())
}
@ -40,12 +43,7 @@ func (suite *LoginSuiteTest) AfterTest(suiteName, testName string) {
}
func (suite *LoginSuiteTest) TestRefreshInValidToken() {
data := url.Values{
"grant_type": []string{"refresh_token"},
"client_id": []string{clientID},
"scope": []string{scopes},
"refresh_token": []string{"refreshToken"},
}
data := refreshTokenData("refreshToken")
suite.mockHelper.On("queryToken", data, "123456").Return(azureToken{
RefreshToken: "newRefreshToken",
AccessToken: "newAccessToken",
@ -98,6 +96,126 @@ func (suite *LoginSuiteTest) TestDoesNotRefreshValidToken() {
Expect(token.AccessToken).To(Equal("accessToken"))
}
func (suite *LoginSuiteTest) TestInvalidLogin() {
suite.mockHelper.On("openAzureLoginPage", mock.AnythingOfType("string")).Run(func(args mock.Arguments) {
redirectURL := args.Get(0).(string)
err := queryKeyValue(redirectURL, "error", "access denied")
Expect(err).To(BeNil())
})
//nolint copylocks
azureLogin, err := newAzureLoginServiceFromPath(filepath.Join(suite.dir, tokenStoreFilename), suite.mockHelper)
Expect(err).To(BeNil())
err = azureLogin.Login(context.TODO())
Expect(err).To(MatchError(errors.New("login failed : [access denied]")))
}
func (suite *LoginSuiteTest) TestValidLogin() {
var redirectURL string
suite.mockHelper.On("openAzureLoginPage", mock.AnythingOfType("string")).Run(func(args mock.Arguments) {
redirectURL = args.Get(0).(string)
err := queryKeyValue(redirectURL, "code", "123456879")
Expect(err).To(BeNil())
})
suite.mockHelper.On("queryToken", mock.MatchedBy(func(data url.Values) bool {
//Need a matcher here because the value of redirectUrl is not known until executing openAzureLoginPage
return reflect.DeepEqual(data, url.Values{
"grant_type": []string{"authorization_code"},
"client_id": []string{clientID},
"code": []string{"123456879"},
"scope": []string{scopes},
"redirect_uri": []string{redirectURL},
})
}), "organizations").Return(azureToken{
RefreshToken: "firstRefreshToken",
AccessToken: "firstAccessToken",
ExpiresIn: 3600,
Foci: "1",
}, nil)
authBody := `{"value":[{"id":"/tenants/12345a7c-c56d-43e8-9549-dd230ce8a038","tenantId":"12345a7c-c56d-43e8-9549-dd230ce8a038"}]}`
suite.mockHelper.On("queryAuthorizationAPI", authorizationURL, "Bearer firstAccessToken").Return([]byte(authBody), 200, nil)
data := refreshTokenData("firstRefreshToken")
suite.mockHelper.On("queryToken", data, "12345a7c-c56d-43e8-9549-dd230ce8a038").Return(azureToken{
RefreshToken: "newRefreshToken",
AccessToken: "newAccessToken",
ExpiresIn: 3600,
Foci: "1",
}, nil)
//nolint copylocks
azureLogin, err := newAzureLoginServiceFromPath(filepath.Join(suite.dir, tokenStoreFilename), suite.mockHelper)
Expect(err).To(BeNil())
err = azureLogin.Login(context.TODO())
Expect(err).To(BeNil())
loginToken, err := suite.azureLogin.tokenStore.readToken()
Expect(err).To(BeNil())
Expect(loginToken.Token.AccessToken).To(Equal("newAccessToken"))
Expect(loginToken.Token.RefreshToken).To(Equal("newRefreshToken"))
Expect(loginToken.Token.Expiry).To(BeTemporally(">", time.Now().Add(3500*time.Second)))
Expect(loginToken.TenantID).To(Equal("12345a7c-c56d-43e8-9549-dd230ce8a038"))
Expect(loginToken.Token.Type()).To(Equal("Bearer"))
}
func (suite *LoginSuiteTest) TestLoginAuthorizationFailed() {
var redirectURL string
suite.mockHelper.On("openAzureLoginPage", mock.AnythingOfType("string")).Run(func(args mock.Arguments) {
redirectURL = args.Get(0).(string)
err := queryKeyValue(redirectURL, "code", "123456879")
Expect(err).To(BeNil())
})
suite.mockHelper.On("queryToken", mock.MatchedBy(func(data url.Values) bool {
//Need a matcher here because the value of redirectUrl is not known until executing openAzureLoginPage
return reflect.DeepEqual(data, url.Values{
"grant_type": []string{"authorization_code"},
"client_id": []string{clientID},
"code": []string{"123456879"},
"scope": []string{scopes},
"redirect_uri": []string{redirectURL},
})
}), "organizations").Return(azureToken{
RefreshToken: "firstRefreshToken",
AccessToken: "firstAccessToken",
ExpiresIn: 3600,
Foci: "1",
}, nil)
authBody := `[access denied]`
suite.mockHelper.On("queryAuthorizationAPI", authorizationURL, "Bearer firstAccessToken").Return([]byte(authBody), 400, nil)
azureLogin, err := newAzureLoginServiceFromPath(filepath.Join(suite.dir, tokenStoreFilename), suite.mockHelper)
Expect(err).To(BeNil())
err = azureLogin.Login(context.TODO())
Expect(err).To(MatchError(errors.New("login failed : [access denied]")))
}
func refreshTokenData(refreshToken string) url.Values {
return url.Values{
"grant_type": []string{"refresh_token"},
"client_id": []string{clientID},
"scope": []string{scopes},
"refresh_token": []string{refreshToken},
}
}
func queryKeyValue(redirectURL string, key string, value string) error {
req, err := http.NewRequest("GET", redirectURL, nil)
Expect(err).To(BeNil())
q := req.URL.Query()
q.Add(key, value)
req.URL.RawQuery = q.Encode()
client := &http.Client{}
_, err = client.Do(req)
return err
}
func TestLoginSuite(t *testing.T) {
RegisterTestingT(t)
suite.Run(t, new(LoginSuiteTest))
@ -107,8 +225,16 @@ type MockAzureHelper struct {
mock.Mock
}
//nolint copylocks
func (s MockAzureHelper) queryToken(data url.Values, tenantID string) (token azureToken, err error) {
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)
}
func (s *MockAzureHelper) queryAuthorizationAPI(authorizationURL string, authorizationHeader string) ([]byte, int, error) {
args := s.Called(authorizationURL, authorizationHeader)
return args.Get(0).([]byte), args.Int(1), args.Error(2)
}
func (s *MockAzureHelper) openAzureLoginPage(redirectURL string) {
s.Called(redirectURL)
}

View File

@ -28,9 +28,10 @@
package context
import (
"github.com/docker/api/cli/cmd/context/login"
"github.com/spf13/cobra"
"github.com/docker/api/cli/cmd/context/login"
cliopts "github.com/docker/api/cli/options"
)

View File

@ -1,8 +1,8 @@
package login
import (
"github.com/spf13/cobra"
"github.com/pkg/errors"
"github.com/spf13/cobra"
"github.com/docker/api/client"
apicontext "github.com/docker/api/context"

View File

@ -29,6 +29,7 @@ package client
import (
"context"
"github.com/docker/api/context/cloud"
"github.com/docker/api/backend"

View File

@ -2,8 +2,8 @@ package cloud
import "context"
// Service cloud specific services
type Service interface {
// Login login to cloud provider
Login(ctx context.Context, params map[string]string) error
}

View File

@ -3,9 +3,10 @@ package example
import (
"context"
"fmt"
"github.com/docker/api/context/cloud"
"io"
"github.com/docker/api/context/cloud"
"github.com/docker/api/backend"
"github.com/docker/api/compose"
"github.com/docker/api/containers"

View File

@ -2,9 +2,10 @@ package moby
import (
"context"
"github.com/docker/api/context/cloud"
"io"
"github.com/docker/api/context/cloud"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/client"