diff --git a/go.mod b/go.mod index 378a24ce..21572310 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/debricked/cli go 1.20 require ( + github.com/becheran/wildmatch-go v1.0.0 github.com/bmatcuk/doublestar/v4 v4.6.0 github.com/chelnak/ysmrr v0.2.1 github.com/fatih/color v1.16.0 @@ -15,22 +16,29 @@ require ( github.com/spf13/viper v1.15.0 github.com/stretchr/testify v1.8.4 github.com/vifraa/gopom v0.2.1 + golang.org/x/oauth2 v0.22.0 golang.org/x/tools v0.19.0 gopkg.in/yaml.v3 v3.0.1 lukechampine.com/blake3 v1.2.1 - github.com/becheran/wildmatch-go v1.0.0 ) require ( dario.cat/mergo v1.0.0 // indirect github.com/Microsoft/go-winio v0.6.1 // indirect github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 // indirect + github.com/alessio/shellescape v1.4.1 // indirect + github.com/cli/browser v1.0.0 // indirect + github.com/cli/safeexec v1.0.0 // indirect github.com/cloudflare/circl v1.3.7 // indirect github.com/cyphar/filepath-securejoin v0.2.4 // indirect + github.com/danieljoos/wincred v1.2.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/fsnotify/fsnotify v1.6.0 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect + github.com/go-oauth2/oauth2 v3.9.2+incompatible // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/golang-jwt/jwt v3.2.2+incompatible // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/hcl v1.0.0 // indirect @@ -46,6 +54,7 @@ require ( github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/pelletier/go-toml/v2 v2.0.7 // indirect github.com/pjbgf/sha1cd v0.3.0 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.4 // indirect github.com/sergi/go-diff v1.3.1 // indirect @@ -57,6 +66,7 @@ require ( github.com/stretchr/objx v0.5.0 // indirect github.com/subosito/gotenv v1.4.2 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect + github.com/zalando/go-keyring v0.2.5 // indirect golang.org/x/crypto v0.21.0 // indirect golang.org/x/mod v0.16.0 // indirect golang.org/x/net v0.22.0 // indirect diff --git a/go.sum b/go.sum index 4af1bbf0..ad105d80 100644 --- a/go.sum +++ b/go.sum @@ -45,6 +45,8 @@ github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migc github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 h1:kkhsdkhsCvIsutKu5zLMgWtgh9YxGCNAw8Ad8hjwfYg= github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0= +github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0= +github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/becheran/wildmatch-go v1.0.0 h1:mE3dGGkTmpKtT4Z+88t8RStG40yN9T+kFEGj2PZFSzA= @@ -58,6 +60,12 @@ github.com/chelnak/ysmrr v0.2.1/go.mod h1:9TEgLy2xDMGN62zJm9XZrEWY/fHoGoBslSVEkE github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/cli/browser v1.0.0 h1:RIleZgXrhdiCVgFBSjtWwkLPUCWyhhhN5k5HGSBt1js= +github.com/cli/browser v1.0.0/go.mod h1:IEWkHYbLjkhtjwwWlwTHW2lGxeS5gezEQBMLTwDHf5Q= +github.com/cli/oauth v1.0.1 h1:pXnTFl/qUegXHK531Dv0LNjW4mLx626eS42gnzfXJPA= +github.com/cli/oauth v1.0.1/go.mod h1:qd/FX8ZBD6n1sVNQO3aIdRxeu5LGw9WhKnYhIIoC2A4= +github.com/cli/safeexec v1.0.0 h1:0VngyaIyqACHdcMNWfo6+KdUYnqEr2Sg+bSP1pdF+dI= +github.com/cli/safeexec v1.0.0/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cloudflare/circl v1.3.3 h1:fE/Qz0QdIGqeWfnwq0RE0R7MI51s0M2E4Ga9kq5AEMs= github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= @@ -69,6 +77,8 @@ github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnht github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg= github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= +github.com/danieljoos/wincred v1.2.0 h1:ozqKHaLK0W/ii4KVbbvluM91W2H3Sh0BncbUNPS7jLE= +github.com/danieljoos/wincred v1.2.0/go.mod h1:FzQLLMKBFdvu+osBrnFODiv32YGwCfx0SkRa/eYHgec= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -99,6 +109,12 @@ github.com/go-git/go-git/v5 v5.11.0/go.mod h1:6GFcX2P3NM7FPBfpePbpLd21XxsgdAt+lK github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-oauth2/oauth2 v3.9.2+incompatible h1:A8gSjq4110EgZDVk4ZtcpusynU2Fto9eM6sXvxL+EOs= +github.com/go-oauth2/oauth2 v3.9.2+incompatible/go.mod h1:GGcZ+i513KxN4yS7zBYfmwo3P+cyGvCS675uCNmWv/g= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -213,6 +229,8 @@ github.com/pelletier/go-toml/v2 v2.0.7 h1:muncTPStnKRos5dpVKULv2FVd4bMOhNePj9Cjg github.com/pelletier/go-toml/v2 v2.0.7/go.mod h1:eumQOmlWiOPt5WriQQqoM5y18pDHwha2N+QD+EUNTek= github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4= github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/profile v1.6.0/go.mod h1:qBsxPvzyUincmltOk6iyRVxHYg4adc0OFOv72ZdLa18= @@ -272,6 +290,8 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zalando/go-keyring v0.2.5 h1:Bc2HHpjALryKD62ppdEzaFG6VxL6Bc+5v0LYpN8Lba8= +github.com/zalando/go-keyring v0.2.5/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= @@ -375,6 +395,8 @@ golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.22.0 h1:BzDx2FehcG7jJwgWLELCdmLuxk2i+x9UDpSiss2u0ZA= +golang.org/x/oauth2 v0.22.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 00000000..c31e59fd --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,167 @@ +package auth + +import ( + "context" + "strings" + + "github.com/debricked/cli/internal/client" + "github.com/golang-jwt/jwt" + "github.com/zalando/go-keyring" + "golang.org/x/oauth2" +) + +type IAuthenticator interface { + Authenticate() error + Logout() error + Token() (*oauth2.Token, error) +} + +type ISecretClient interface { + Set(string, string) error + Get(string) (string, error) + Delete(string) error +} + +type IOAuthConfig interface { + AuthCodeURL(string, ...oauth2.AuthCodeOption) string + Exchange(context.Context, string, ...oauth2.AuthCodeOption) (*oauth2.Token, error) + TokenSource(context.Context, *oauth2.Token) oauth2.TokenSource +} // Wrapping interface for config to simplify mocking + +type Authenticator struct { + SecretClient ISecretClient + OAuthConfig IOAuthConfig + AuthWebHelper IAuthWebHelper +} + +type DebrickedSecretClient struct { + User string +} + +func (dsc DebrickedSecretClient) Set(service, secret string) error { + return keyring.Set(service, dsc.User, secret) +} + +func (dsc DebrickedSecretClient) Get(service string) (string, error) { + return keyring.Get(service, dsc.User) +} + +func (dsc DebrickedSecretClient) Delete(service string) error { + return keyring.Delete(service, dsc.User) +} + +func NewDebrickedAuthenticator(client client.IDebClient) Authenticator { + return Authenticator{ + SecretClient: DebrickedSecretClient{ + User: "DebrickedCLI", + }, + OAuthConfig: &oauth2.Config{ + ClientID: "01919462-7d6e-78e8-aa24-ba779213c90f", + ClientSecret: "", + Endpoint: oauth2.Endpoint{ + AuthURL: client.Host() + "/app/oauth/authorize", + TokenURL: client.Host() + "/app/oauth/token", + }, + RedirectURL: "http://localhost:9096/callback", + Scopes: []string{"select", "profile", "basicRepo"}, + }, + AuthWebHelper: NewAuthWebHelper(), + } +} + +func (a Authenticator) Logout() error { + err := a.SecretClient.Delete("DebrickedRefreshToken") + if err != nil { + return err + } + + return a.SecretClient.Delete("DebrickedAccessToken") +} + +func validateJWT(token string) error { + claims := jwt.MapClaims{} + _, err := jwt.ParseWithClaims(token, claims, nil) + if err != nil && strings.Compare(err.Error(), "no Keyfunc was provided.") != 0 { + + return err + } + + return claims.Valid() +} + +func (a Authenticator) Token() (*oauth2.Token, error) { + refreshToken, err := a.SecretClient.Get("DebrickedRefreshToken") + if err != nil { + return nil, err + } + accessToken, err := a.SecretClient.Get("DebrickedAccessToken") + if err != nil { + return nil, err + } + jwtErr := validateJWT(accessToken) + if jwtErr != nil { + if jwtErr.Error() == "Token is expired" { + return a.refresh(refreshToken) + } else { + return nil, jwtErr + } + } + + return &oauth2.Token{ + RefreshToken: refreshToken, + AccessToken: accessToken, + }, nil +} + +func (a Authenticator) save(token *oauth2.Token) error { + err := a.SecretClient.Set("DebrickedRefreshToken", token.RefreshToken) + if err != nil { + return err + } + + return a.SecretClient.Set("DebrickedAccessToken", token.AccessToken) +} + +func (a Authenticator) refresh(refreshToken string) (*oauth2.Token, error) { + token := &oauth2.Token{ + RefreshToken: refreshToken, + } + tokenSource := a.OAuthConfig.TokenSource( + context.Background(), + token, + ) + token, err := tokenSource.Token() + if err != nil { + return nil, err + } else { + err = a.save(token) + + return token, err + } +} + +func (a Authenticator) Authenticate() error { + state := oauth2.GenerateVerifier() + codeVerifier := oauth2.GenerateVerifier() + authURL := a.OAuthConfig.AuthCodeURL( + state, + oauth2.S256ChallengeOption(codeVerifier), + ) + + err := a.AuthWebHelper.OpenURL(authURL) + if err != nil { + return err + } + + authCode := a.AuthWebHelper.Callback(state) + token, err := a.OAuthConfig.Exchange( + context.Background(), + authCode, + oauth2.VerifierOption(codeVerifier), + ) + if err != nil { + return err + } + + return a.save(token) +} diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go new file mode 100644 index 00000000..53d9bdbb --- /dev/null +++ b/internal/auth/auth_test.go @@ -0,0 +1,262 @@ +package auth + +import ( + "runtime" + "testing" + + "github.com/debricked/cli/internal/auth/testdata" + clientTestdata "github.com/debricked/cli/internal/client/testdata" + "github.com/stretchr/testify/assert" + "github.com/zalando/go-keyring" + "golang.org/x/oauth2" +) + +const windowsOS = "windows" +const macOS = "darwin" + +func TestNewAuthenticator(t *testing.T) { + res := NewDebrickedAuthenticator(clientTestdata.NewDebClientMock()) + assert.NotNil(t, res) +} + +func TestSecretClientSet(t *testing.T) { + if runtime.GOOS != windowsOS && runtime.GOOS != macOS { + t.Skipf("TestSecretClient is skipped due to env without secret client") + } + user := "TestDebrickedCLIUserSet" + service := "TestDebrickedCLIServiceSet" + secret := "TestDebrickedCLISecretSet" + dsc := DebrickedSecretClient{user} + _, err := keyring.Get(service, user) + assert.Error(t, err) + err = dsc.Set(service, secret) + assert.NoError(t, err) + savedSecret, err := keyring.Get(service, user) + assert.NoError(t, err) + err = keyring.Delete(service, user) + assert.NoError(t, err) + assert.Equal(t, secret, savedSecret) +} + +func TestSecretClientGet(t *testing.T) { + if runtime.GOOS != windowsOS && runtime.GOOS != macOS { + t.Skipf("TestSecretClient is skipped due to env without secret client") + } + user := "TestDebrickedCLIUserGet" + service := "TestDebrickedCLIServiceGet" + secret := "TestDebrickedCLISecretGet" + dsc := DebrickedSecretClient{user} + err := keyring.Set(service, user, secret) + assert.NoError(t, err) + savedSecret, err := dsc.Get(service) + assert.NoError(t, err) + err = keyring.Delete(service, user) + assert.NoError(t, err) + assert.Equal(t, secret, savedSecret) +} + +func TestSecretClientDelete(t *testing.T) { + if runtime.GOOS != windowsOS && runtime.GOOS != macOS { + t.Skipf("TestSecretClient is skipped due to env without secret client") + } + user := "TestDebrickedCLIUserDelete" + service := "TestDebrickedCLIServiceDelete" + secret := "TestDebrickedCLISecretDelete" + dsc := DebrickedSecretClient{user} + err := keyring.Set(service, user, secret) + assert.NoError(t, err) + savedSecret, err := keyring.Get(service, user) + assert.NoError(t, err) + assert.Equal(t, secret, savedSecret) + err = dsc.Delete(service) + assert.NoError(t, err) + _, err = keyring.Get(service, user) + assert.Error(t, err) +} + +func TestMockedLogout(t *testing.T) { + authenticator := Authenticator{ + SecretClient: testdata.MockSecretClient{}, + OAuthConfig: nil, + } + err := authenticator.Logout() + + assert.NoError(t, err) +} + +func TestMockedLogoutErrorRefresh(t *testing.T) { + authenticator := Authenticator{ + SecretClient: testdata.MockErrorSecretClient{ + ErrorPattern: "Refresh", + }, + OAuthConfig: nil, + } + err := authenticator.Logout() + + assert.Error(t, err) +} + +func TestMockedSaveToken(t *testing.T) { + authenticator := Authenticator{ + SecretClient: testdata.MockSecretClient{}, + OAuthConfig: nil, + } + token := &oauth2.Token{ + RefreshToken: "refreshToken", + AccessToken: "accessToken", + } + err := authenticator.save(token) + + assert.NoError(t, err) +} + +func TestMockedSaveTokenRefreshError(t *testing.T) { + authenticator := Authenticator{ + SecretClient: testdata.MockErrorSecretClient{ + ErrorPattern: "Refresh", + }, + OAuthConfig: nil, + } + token := &oauth2.Token{ + RefreshToken: "refreshToken", + AccessToken: "accessToken", + } + err := authenticator.save(token) + + assert.Error(t, err) +} + +func TestMockedTokenErrorSegments(t *testing.T) { + authenticator := Authenticator{ + SecretClient: testdata.MockInvalidSecretClient{}, + OAuthConfig: nil, + } + token, err := authenticator.Token() + assert.Error(t, err) + assert.ErrorContains(t, err, "token contains an invalid number of segments") + assert.Nil(t, token) +} + +func TestMockedToken(t *testing.T) { + authenticator := Authenticator{ + SecretClient: testdata.MockSecretClient{}, + OAuthConfig: nil, + } + token, err := authenticator.Token() + assert.NoError(t, err) + assert.NotNil(t, token) +} + +func TestMockedTokenExpired(t *testing.T) { + authenticator := Authenticator{ + SecretClient: testdata.MockExpiredSecretClient{}, + OAuthConfig: testdata.MockOAuthConfig{ + MockTokenSource: testdata.MockTokenSource{ + StaticToken: &oauth2.Token{ + RefreshToken: "refreshToken", + AccessToken: "accessToken", + }, + Error: nil, + }, + }, + } + _, err := authenticator.Token() + + assert.NoError(t, err) +} + +func TestMockedTokenRefreshError(t *testing.T) { + authenticator := Authenticator{ + SecretClient: testdata.MockErrorSecretClient{ + ErrorPattern: "Refresh", + }, + OAuthConfig: nil, + } + _, err := authenticator.Token() + + assert.Error(t, err) +} + +func TestMockedTokenAccessError(t *testing.T) { + authenticator := Authenticator{ + SecretClient: testdata.MockErrorSecretClient{ + ErrorPattern: "Access", + }, + OAuthConfig: nil, + } + _, err := authenticator.Token() + + assert.Error(t, err) +} + +func TestMockedAuthenticate(t *testing.T) { + authenticator := Authenticator{ + SecretClient: testdata.MockSecretClient{}, + OAuthConfig: testdata.MockOAuthConfig{}, + AuthWebHelper: testdata.MockAuthWebHelper{}, + } + err := authenticator.Authenticate() + + assert.NoError(t, err) +} + +func TestMockedAuthenticateExchangeError(t *testing.T) { + authenticator := Authenticator{ + SecretClient: testdata.MockSecretClient{}, + OAuthConfig: testdata.MockOAuthConfigExchangeError{}, + AuthWebHelper: testdata.MockAuthWebHelper{}, + } + err := authenticator.Authenticate() + + assert.Error(t, err) + assert.Equal(t, "HTTP Error", err.Error()) +} + +func TestMockedRefresh(t *testing.T) { + authenticator := Authenticator{ + SecretClient: testdata.MockSecretClient{}, + OAuthConfig: testdata.MockOAuthConfig{ + MockTokenSource: testdata.MockTokenSource{ + StaticToken: &oauth2.Token{ + RefreshToken: "refreshToken", + AccessToken: "accessToken", + }, + Error: nil, + }, + }, + AuthWebHelper: testdata.MockAuthWebHelper{}, + } + token, err := authenticator.refresh("refreshToken") + + assert.NoError(t, err) + assert.Equal(t, "accessToken", token.AccessToken) +} + +func TestMockedRefreshError(t *testing.T) { + authenticator := Authenticator{ + SecretClient: testdata.MockSecretClient{}, + OAuthConfig: testdata.MockOAuthConfig{ + MockTokenSource: testdata.MockTokenSource{ + StaticToken: nil, + Error: testdata.MockError{ + Message: "testerror", + }, + }, + }, + AuthWebHelper: testdata.MockAuthWebHelper{}, + } + _, err := authenticator.refresh("refreshToken") + + assert.Error(t, err) +} + +func TestMockedAuthenticateOpenURLError(t *testing.T) { + authenticator := Authenticator{ + SecretClient: testdata.MockSecretClient{}, + OAuthConfig: testdata.MockOAuthConfig{}, + AuthWebHelper: testdata.MockErrorAuthWebHelper{}, + } + err := authenticator.Authenticate() + + assert.Error(t, err) +} diff --git a/internal/auth/callback.go b/internal/auth/callback.go new file mode 100644 index 00000000..b918b30d --- /dev/null +++ b/internal/auth/callback.go @@ -0,0 +1,67 @@ +package auth + +import ( + "context" + "fmt" + "log" + "net/http" + "time" + + "github.com/pkg/browser" +) + +type IAuthWebHelper interface { + Callback(string) string + OpenURL(string) error +} + +type AuthWebHelper struct { + ServeMux *http.ServeMux +} + +func NewAuthWebHelper() AuthWebHelper { + mux := http.NewServeMux() + + return AuthWebHelper{ + ServeMux: mux, + } +} + +func (awh AuthWebHelper) Callback(state string) string { + code := make(chan string) + defer close(code) + + awh.ServeMux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("state") != state { + http.Error(w, "Invalid state", http.StatusBadRequest) + + return + } + + code <- r.URL.Query().Get("code") + fmt.Fprintf(w, "Authentication successful! You can close this window now.") + }) + + server := &http.Server{ + Addr: ":9096", + ReadHeaderTimeout: time.Minute, + Handler: awh.ServeMux, + } + go func() { + if err := server.ListenAndServe(); err != http.ErrServerClosed { + log.Fatalf("HTTP server error: %v", err) + } + }() + defer func() { + if err := server.Shutdown(context.Background()); err != nil { + log.Fatalf("HTTP server shutdown error: %v", err) + } + }() + authCode := <-code // Wait for the authorization code + + return authCode +} + +func (awh AuthWebHelper) OpenURL(authURL string) error { + return browser.OpenURL(authURL) +} diff --git a/internal/auth/callback_test.go b/internal/auth/callback_test.go new file mode 100644 index 00000000..d62c1026 --- /dev/null +++ b/internal/auth/callback_test.go @@ -0,0 +1,100 @@ +package auth + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +const testState = "test_state" + +func TestCallback(t *testing.T) { + awh := NewAuthWebHelper() + + resultChan := make(chan string) + go func() { + result := awh.Callback(testState) + resultChan <- result + }() + + time.Sleep(100 * time.Millisecond) + + testCode := "test_code" + resp, err := http.Get(fmt.Sprintf("http://localhost:9096/callback?state=%s&code=%s", testState, testCode)) + if err != nil { + t.Fatalf("Failed to make callback request: %v", err) + } + defer func() { + err := resp.Body.Close() + assert.NoError(t, err) + }() + if resp.StatusCode != http.StatusOK { + t.Errorf("Expected status OK, got %v", resp.Status) + } + + select { + case result := <-resultChan: + if result != testCode { + t.Errorf("Expected code %s, got %s", testCode, result) + } + case <-time.After(5 * time.Second): + t.Fatal("Test timed out") + } +} + +func TestCallbackInvalidState(t *testing.T) { + awh := NewAuthWebHelper() + + go func() { + awh.Callback(testState) + }() + + time.Sleep(100 * time.Millisecond) + + resp, err := http.Get("http://localhost:9096/callback?state=invalid_state&code=test_code") + if err != nil { + t.Fatalf("Failed to make callback request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("Expected status Bad Request, got %v", resp.Status) + } +} + +func TestCallbackServerError(t *testing.T) { + server := &http.Server{Addr: ":9096", ReadHeaderTimeout: time.Second} + go func() { + err := server.ListenAndServe() + assert.Error(t, err) // Two servers trying to run on localhost:9096 + }() + defer func() { + err := server.Shutdown(context.Background()) + assert.NoError(t, err) + }() + + awh := AuthWebHelper{} + + resultChan := make(chan error) + go func() { + defer func() { + if r := recover(); r != nil { + resultChan <- fmt.Errorf("panic occurred: %v", r) + } else { + resultChan <- nil + } + }() + awh.Callback(testState) + }() + + select { + case err := <-resultChan: + assert.Error(t, err) + case <-time.After(2 * time.Second): + t.Fatal("Test timed out") + } +} diff --git a/internal/auth/testdata/auth_mock.go b/internal/auth/testdata/auth_mock.go new file mode 100644 index 00000000..064352b7 --- /dev/null +++ b/internal/auth/testdata/auth_mock.go @@ -0,0 +1,211 @@ +package testdata + +import ( + "context" + "strings" + "time" + + "github.com/golang-jwt/jwt" + "golang.org/x/oauth2" +) + +type MockError struct { + Message string +} + +func (me MockError) Error() string { + return me.Message +} + +type MockSecretClient struct{} + +type MockExpiredSecretClient struct{} + +type MockInvalidSecretClient struct{} + +func (msc MockSecretClient) Set(service, secret string) error { + return nil +} + +func (msc MockSecretClient) Get(service string) (string, error) { + var secretKey = []byte("secret-key") + token := jwt.NewWithClaims( + jwt.SigningMethodHS256, + jwt.MapClaims{ + "username": "mockDebrickedClient", + "exp": time.Now().Add(time.Hour).Unix(), + }, + ) + + return token.SignedString(secretKey) +} + +func (msc MockSecretClient) Delete(service string) error { + return nil +} + +func (msc MockExpiredSecretClient) Set(service, secret string) error { + return nil +} + +func (msc MockExpiredSecretClient) Get(service string) (string, error) { + var secretKey = []byte("secret-key") + token := jwt.NewWithClaims( + jwt.SigningMethodHS256, + jwt.MapClaims{ + "username": "mockDebrickedClient", + "exp": time.Now().Add(-time.Hour).Unix(), + }, + ) + + return token.SignedString(secretKey) +} + +func (msc MockExpiredSecretClient) Delete(service string) error { + return nil +} + +func (msc MockInvalidSecretClient) Set(service, secret string) error { + return nil +} + +func (msc MockInvalidSecretClient) Get(service string) (string, error) { + return "eyJhdWQiOiIwMTkxOTQ2Mi03ZDZlLTc4ZTgtYWEyNC1iYTc3OTIxM2M5MGYiLCJqdGkiOiJlMTdhMmFlYTk0ZjgyNTdjYWU1NWM3ZjRiNTczNTRiMzI2YmNiYTZiZmY3ZGQ0ZWQ2NjU3NDA4MWE4ODFjN2VhMmM3OGU3Y2EzM2UxMjU5MyIsImlhdCI6MTY5NDU5NzkzNy4zNjAwMTUsIm5iZiI6MTY5NDU5NzkzNy4zNjAwMTcsImV4cCI6MTY5NDU5NzkzNy4zNTM3MDMsInN1YiI6ImZpbGlwLmhlZGVuK2FkbWluQGRlYnJpY2tlZC5jb20iLCJzY29wZXMiOlsic2VsZWN0IiwicHJvZmlsZSIsImJhc2ljUmVwbyJdfQ", nil +} + +func (msc MockInvalidSecretClient) Delete(service string) error { + return nil +} + +type MockErrorSecretClient struct { + ErrorPattern string + Message string +} + +func (msc MockErrorSecretClient) Set(service, secret string) error { + if strings.Contains(service, msc.ErrorPattern) { + + return MockError{ + Message: msc.Message, + } + } + + return nil +} + +func (msc MockErrorSecretClient) Get(service string) (string, error) { + if strings.Contains(service, msc.ErrorPattern) { + + return "", MockError{ + Message: msc.Message, + } + } + return "token", nil +} + +func (msc MockErrorSecretClient) Delete(service string) error { + if strings.Contains(service, msc.ErrorPattern) { + + return MockError{ + Message: msc.Message, + } + } + return nil +} + +type MockAuthenticator struct{} + +type ErrorMockAuthenticator struct{} + +type MockOAuthConfig struct { + MockTokenSource oauth2.TokenSource +} + +type MockOAuthConfigExchangeError struct{} + +type MockAuthWebHelper struct{} + +type MockErrorAuthWebHelper struct{} + +func (ma MockAuthenticator) Authenticate() error { + return nil +} + +func (ma MockAuthenticator) Logout() error { + return nil +} + +func (ma MockAuthenticator) Token() (*oauth2.Token, error) { + return &oauth2.Token{ + RefreshToken: "refresh", + AccessToken: "access", + TokenType: "jwt", + }, nil +} + +func (ma ErrorMockAuthenticator) Authenticate() error { + return MockError{""} +} + +func (ma ErrorMockAuthenticator) Logout() error { + return MockError{""} +} + +func (ma ErrorMockAuthenticator) Token() (*oauth2.Token, error) { + return nil, MockError{""} +} + +func (mawh MockAuthWebHelper) OpenURL(string) error { + return nil +} + +func (mawh MockAuthWebHelper) Callback(string) string { + return "callback" +} + +func (mawh MockErrorAuthWebHelper) OpenURL(string) error { + return MockError{} +} + +func (mawh MockErrorAuthWebHelper) Callback(string) string { + return "callback" +} + +func (moc MockOAuthConfig) Exchange(context.Context, string, ...oauth2.AuthCodeOption) (*oauth2.Token, error) { + return &oauth2.Token{ + AccessToken: "accessToken", + RefreshToken: "accessToken", + }, nil +} + +func (moc MockOAuthConfig) AuthCodeURL(string, ...oauth2.AuthCodeOption) string { + return "localhost" +} + +func (moc MockOAuthConfigExchangeError) Exchange(context.Context, string, ...oauth2.AuthCodeOption) (*oauth2.Token, error) { + return nil, MockError{Message: "HTTP Error"} +} + +func (moc MockOAuthConfigExchangeError) AuthCodeURL(string, ...oauth2.AuthCodeOption) string { + return "localhost" +} + +func (moc MockOAuthConfigExchangeError) TokenSource(context.Context, *oauth2.Token) oauth2.TokenSource { + return nil +} + +type MockTokenSource struct { + StaticToken *oauth2.Token + Error error +} + +func (mts MockTokenSource) Token() (*oauth2.Token, error) { + if mts.Error != nil { + return nil, mts.Error + } + return mts.StaticToken, nil +} + +func (moc MockOAuthConfig) TokenSource(context.Context, *oauth2.Token) oauth2.TokenSource { + return moc.MockTokenSource +} diff --git a/internal/client/deb_client.go b/internal/client/deb_client.go index 0a61f6af..dc98bbd7 100644 --- a/internal/client/deb_client.go +++ b/internal/client/deb_client.go @@ -22,6 +22,7 @@ type IDebClient interface { Get(uri string, format string) (*http.Response, error) SetAccessToken(accessToken *string) IsEnterpriseCustomer(silent bool) bool + Host() string } type DebClient struct { @@ -45,6 +46,10 @@ func NewDebClient(accessToken *string, httpClient IClient) *DebClient { } } +func (debClient *DebClient) Host() string { + return *debClient.host +} + func (debClient *DebClient) Post(uri string, contentType string, body *bytes.Buffer, timeout int) (*http.Response, error) { if timeout > 0 { return postWithTimeout(uri, debClient, contentType, body, true, timeout) diff --git a/internal/client/testdata/deb_client_mock.go b/internal/client/testdata/deb_client_mock.go index d897e3db..60feb184 100644 --- a/internal/client/testdata/deb_client_mock.go +++ b/internal/client/testdata/deb_client_mock.go @@ -34,6 +34,10 @@ func NewDebClientMock() *DebClientMock { } } +func (mock *DebClientMock) Host() string { + return "debricked.com" +} + func (mock *DebClientMock) Get(uri string, format string) (*http.Response, error) { response, err := mock.popResponse(mock.RemoveQueryParamsFromUri(uri)) diff --git a/internal/cmd/auth/auth.go b/internal/cmd/auth/auth.go new file mode 100644 index 00000000..8953ddf4 --- /dev/null +++ b/internal/cmd/auth/auth.go @@ -0,0 +1,29 @@ +package auth + +import ( + "log" + + "github.com/debricked/cli/internal/auth" + "github.com/debricked/cli/internal/cmd/auth/login" + "github.com/debricked/cli/internal/cmd/auth/logout" + "github.com/debricked/cli/internal/cmd/auth/token" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +func NewAuthCmd(authenticator auth.IAuthenticator) *cobra.Command { + cmd := &cobra.Command{ + Use: "auth", + Short: "Debricked authentication. [beta feature]", + Long: `Debricked service authentication. Currently in beta and will most likely not work as expected`, + PreRun: func(cmd *cobra.Command, _ []string) { + _ = viper.BindPFlags(cmd.Flags()) + }, + } + log.Println("Warning: Authentication is beta and may not work as expected.") + cmd.AddCommand(login.NewLoginCmd(authenticator)) + cmd.AddCommand(logout.NewLogoutCmd(authenticator)) + cmd.AddCommand(token.NewTokenCmd(authenticator)) + + return cmd +} diff --git a/internal/cmd/auth/auth_test.go b/internal/cmd/auth/auth_test.go new file mode 100644 index 00000000..b10f08f6 --- /dev/null +++ b/internal/cmd/auth/auth_test.go @@ -0,0 +1,24 @@ +package auth + +import ( + "testing" + + "github.com/debricked/cli/internal/auth" + "github.com/debricked/cli/internal/client" + "github.com/stretchr/testify/assert" +) + +func TestNewAuthCmd(t *testing.T) { + token := "token" + deb_client := client.NewDebClient(&token, nil) + authenticator := auth.NewDebrickedAuthenticator(deb_client) + cmd := NewAuthCmd(authenticator) + commands := cmd.Commands() + nbrOfCommands := 3 + assert.Lenf(t, commands, nbrOfCommands, "failed to assert that there were %d sub commands connected", nbrOfCommands) +} + +func TestPreRun(t *testing.T) { + cmd := NewAuthCmd(nil) + cmd.PreRun(cmd, nil) +} diff --git a/internal/cmd/auth/login/login.go b/internal/cmd/auth/login/login.go new file mode 100644 index 00000000..b2c697f2 --- /dev/null +++ b/internal/cmd/auth/login/login.go @@ -0,0 +1,39 @@ +package login + +import ( + "fmt" + + "github.com/debricked/cli/internal/auth" + "github.com/fatih/color" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +func NewLoginCmd(authenticator auth.IAuthenticator) *cobra.Command { + cmd := &cobra.Command{ + Use: "login", + Short: "Authenticate debricked user", + Long: `Start authentication flow to generate access token.`, + PreRun: func(cmd *cobra.Command, _ []string) { + _ = viper.BindPFlags(cmd.Flags()) + }, + RunE: RunE(authenticator), + } + + return cmd +} + +func RunE(a auth.IAuthenticator) func(_ *cobra.Command, args []string) error { + return func(cmd *cobra.Command, _ []string) error { + err := a.Authenticate() + if err != nil { + return err + } + fmt.Printf( + "%s Successfully authenticated", + color.GreenString("✔"), + ) + + return nil + } +} diff --git a/internal/cmd/auth/login/login_test.go b/internal/cmd/auth/login/login_test.go new file mode 100644 index 00000000..bba8f0cc --- /dev/null +++ b/internal/cmd/auth/login/login_test.go @@ -0,0 +1,44 @@ +package login + +import ( + "testing" + + "github.com/debricked/cli/internal/auth" + "github.com/debricked/cli/internal/auth/testdata" + "github.com/debricked/cli/internal/client" + "github.com/stretchr/testify/assert" +) + +func TestNewLoginCmd(t *testing.T) { + token := "token" + deb_client := client.NewDebClient(&token, nil) + authenticator := auth.NewDebrickedAuthenticator(deb_client) + cmd := NewLoginCmd(authenticator) + commands := cmd.Commands() + nbrOfCommands := 0 + assert.Len(t, commands, nbrOfCommands) +} + +func TestPreRun(t *testing.T) { + mockAuthenticator := testdata.MockAuthenticator{} + cmd := NewLoginCmd(mockAuthenticator) + cmd.PreRun(cmd, nil) +} + +func TestRunE(t *testing.T) { + a := testdata.MockAuthenticator{} + runE := RunE(a) + + err := runE(nil, []string{}) + + assert.NoError(t, err) +} + +func TestRunEError(t *testing.T) { + a := testdata.ErrorMockAuthenticator{} + runE := RunE(a) + + err := runE(nil, []string{}) + + assert.Error(t, err) +} diff --git a/internal/cmd/auth/logout/logout.go b/internal/cmd/auth/logout/logout.go new file mode 100644 index 00000000..840af21f --- /dev/null +++ b/internal/cmd/auth/logout/logout.go @@ -0,0 +1,39 @@ +package logout + +import ( + "fmt" + + "github.com/debricked/cli/internal/auth" + "github.com/fatih/color" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +func NewLogoutCmd(authenticator auth.IAuthenticator) *cobra.Command { + cmd := &cobra.Command{ + Use: "logout", + Short: "Logout debricked user", + Long: `Remove cached credentials to logout debricked user.`, + PreRun: func(cmd *cobra.Command, _ []string) { + _ = viper.BindPFlags(cmd.Flags()) + }, + RunE: RunE(authenticator), + } + + return cmd +} + +func RunE(a auth.IAuthenticator) func(_ *cobra.Command, args []string) error { + return func(cmd *cobra.Command, _ []string) error { + err := a.Logout() + if err != nil { + return err + } + fmt.Printf( + "%s Successfully removed credentials", + color.GreenString("✔"), + ) + + return nil + } +} diff --git a/internal/cmd/auth/logout/logout_test.go b/internal/cmd/auth/logout/logout_test.go new file mode 100644 index 00000000..5bb95f0e --- /dev/null +++ b/internal/cmd/auth/logout/logout_test.go @@ -0,0 +1,44 @@ +package logout + +import ( + "testing" + + "github.com/debricked/cli/internal/auth" + "github.com/debricked/cli/internal/auth/testdata" + "github.com/debricked/cli/internal/client" + "github.com/stretchr/testify/assert" +) + +func TestNewLogoutCmd(t *testing.T) { + token := "token" + deb_client := client.NewDebClient(&token, nil) + authenticator := auth.NewDebrickedAuthenticator(deb_client) + cmd := NewLogoutCmd(authenticator) + commands := cmd.Commands() + nbrOfCommands := 0 + assert.Len(t, commands, nbrOfCommands) +} + +func TestPreRun(t *testing.T) { + mockAuthenticator := testdata.MockAuthenticator{} + cmd := NewLogoutCmd(mockAuthenticator) + cmd.PreRun(cmd, nil) +} + +func TestRunE(t *testing.T) { + a := testdata.MockAuthenticator{} + runE := RunE(a) + + err := runE(nil, []string{}) + + assert.NoError(t, err) +} + +func TestRunEError(t *testing.T) { + a := testdata.ErrorMockAuthenticator{} + runE := RunE(a) + + err := runE(nil, []string{}) + + assert.Error(t, err) +} diff --git a/internal/cmd/auth/token/token.go b/internal/cmd/auth/token/token.go new file mode 100644 index 00000000..44b4aa48 --- /dev/null +++ b/internal/cmd/auth/token/token.go @@ -0,0 +1,61 @@ +package token + +import ( + "encoding/json" + "fmt" + + "github.com/debricked/cli/internal/auth" + + "github.com/fatih/color" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var jsonFormat bool + +const JsonFlag = "json" + +func NewTokenCmd(authenticator auth.IAuthenticator) *cobra.Command { + cmd := &cobra.Command{ + Use: "token", + Short: "Retrieve access token", + Long: `Retrieve access token for currently logged in Debricked user.`, + PreRun: func(cmd *cobra.Command, _ []string) { + _ = viper.BindPFlags(cmd.Flags()) + }, + RunE: RunE(authenticator), + } + cmd.Flags().BoolVarP(&jsonFormat, JsonFlag, "j", false, `Print files in JSON format +Format: +[ + { + "access_token": , + "refresh_token": , + }, +] +`) + viper.MustBindEnv(JsonFlag) + + return cmd +} + +func RunE(a auth.IAuthenticator) func(_ *cobra.Command, args []string) error { + return func(cmd *cobra.Command, _ []string) error { + token, err := a.Token() + if err != nil { + return err + } + if viper.GetBool(JsonFlag) { + jsonToken, _ := json.Marshal(token) + fmt.Println(string(jsonToken)) + } else { + fmt.Printf( + "Refresh Token = %s\nAccess Token = %s\n", + color.BlueString(token.RefreshToken), + color.BlueString(token.AccessToken), + ) + } + + return nil + } +} diff --git a/internal/cmd/auth/token/token_test.go b/internal/cmd/auth/token/token_test.go new file mode 100644 index 00000000..b684ef1f --- /dev/null +++ b/internal/cmd/auth/token/token_test.go @@ -0,0 +1,67 @@ +package token + +import ( + "testing" + + "github.com/debricked/cli/internal/auth" + "github.com/debricked/cli/internal/auth/testdata" + "github.com/debricked/cli/internal/client" + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" +) + +func TestNewTokenCmd(t *testing.T) { + token := "token" + deb_client := client.NewDebClient(&token, nil) + authenticator := auth.NewDebrickedAuthenticator(deb_client) + cmd := NewTokenCmd(authenticator) + commands := cmd.Commands() + nbrOfCommands := 0 + assert.Len(t, commands, nbrOfCommands) + + flags := cmd.Flags() + flagAssertions := map[string]string{} + for name, shorthand := range flagAssertions { + flag := flags.Lookup(name) + assert.NotNil(t, flag) + assert.Equal(t, shorthand, flag.Shorthand) + } + + var flagKeys = []string{ + JsonFlag, + } + viperKeys := viper.AllKeys() + for _, flagKey := range flagKeys { + match := false + for _, key := range viperKeys { + if key == flagKey { + match = true + } + } + assert.Truef(t, match, "failed to assert that flag was present: "+flagKey) + } +} + +func TestPreRun(t *testing.T) { + mockAuthenticator := testdata.MockAuthenticator{} + cmd := NewTokenCmd(mockAuthenticator) + cmd.PreRun(cmd, nil) +} + +func TestRunE(t *testing.T) { + a := testdata.MockAuthenticator{} + runE := RunE(a) + + err := runE(nil, []string{}) + + assert.NoError(t, err) +} + +func TestRunEError(t *testing.T) { + a := testdata.ErrorMockAuthenticator{} + runE := RunE(a) + + err := runE(nil, []string{}) + + assert.Error(t, err) +} diff --git a/internal/cmd/root/root.go b/internal/cmd/root/root.go index ba36b774..a98177f3 100644 --- a/internal/cmd/root/root.go +++ b/internal/cmd/root/root.go @@ -1,6 +1,7 @@ package root import ( + "github.com/debricked/cli/internal/cmd/auth" "github.com/debricked/cli/internal/cmd/callgraph" "github.com/debricked/cli/internal/cmd/files" "github.com/debricked/cli/internal/cmd/fingerprint" @@ -47,6 +48,7 @@ Read more: https://docs.debricked.com/product/administration/generate-access-tok rootCmd.AddCommand(fingerprint.NewFingerprintCmd(container.Fingerprinter())) rootCmd.AddCommand(resolve.NewResolveCmd(container.Resolver())) rootCmd.AddCommand(callgraph.NewCallgraphCmd(container.CallgraphGenerator())) + rootCmd.AddCommand(auth.NewAuthCmd(container.Authenticator())) rootCmd.CompletionOptions.DisableDefaultCmd = true diff --git a/internal/cmd/root/root_test.go b/internal/cmd/root/root_test.go index a9a49731..cb6ce145 100644 --- a/internal/cmd/root/root_test.go +++ b/internal/cmd/root/root_test.go @@ -11,9 +11,13 @@ import ( func TestNewRootCmd(t *testing.T) { cmd := NewRootCmd("v0.0.0", wire.GetCliContainer()) commands := cmd.Commands() - nbrOfCommands := 6 + nbrOfCommands := 7 if len(commands) != nbrOfCommands { - t.Errorf("failed to assert that there were %d sub commands connected", nbrOfCommands) + t.Errorf( + "failed to assert that there were %d sub commands connected (was %d)", + nbrOfCommands, + len(commands), + ) } flags := cmd.PersistentFlags() diff --git a/internal/file/finder_test.go b/internal/file/finder_test.go index 991caa90..381e9d87 100644 --- a/internal/file/finder_test.go +++ b/internal/file/finder_test.go @@ -20,6 +20,10 @@ import ( type debClientMock struct{} +func (mock *debClientMock) Host() string { + return "debricked.com" +} + func (mock *debClientMock) Post(_ string, _ string, _ *bytes.Buffer, _ int) (*http.Response, error) { return &http.Response{}, nil } diff --git a/internal/upload/uploader_test.go b/internal/upload/uploader_test.go index 5d3fb970..22483ba4 100644 --- a/internal/upload/uploader_test.go +++ b/internal/upload/uploader_test.go @@ -90,6 +90,10 @@ func TestUploadPollingError(t *testing.T) { type debClientMock struct{} +func (mock *debClientMock) Host() string { + return "debricked.com" +} + func (mock *debClientMock) Post(uri string, _ string, _ *bytes.Buffer, _ int) (*http.Response, error) { res := &http.Response{ Status: "", diff --git a/internal/wire/cli_container.go b/internal/wire/cli_container.go index 9fbe1d33..f232f16a 100644 --- a/internal/wire/cli_container.go +++ b/internal/wire/cli_container.go @@ -3,6 +3,7 @@ package wire import ( "fmt" + "github.com/debricked/cli/internal/auth" "github.com/debricked/cli/internal/callgraph" callgraphStrategy "github.com/debricked/cli/internal/callgraph/strategy" "github.com/debricked/cli/internal/ci" @@ -91,6 +92,7 @@ func (cc *CliContainer) wire() error { cc.licenseReporter = licenseReport.Reporter{DebClient: cc.debClient} cc.vulnerabilityReporter = vulnerabilityReport.Reporter{DebClient: cc.debClient} + cc.authenticator = auth.NewDebrickedAuthenticator(cc.debClient) return nil } @@ -112,6 +114,7 @@ type CliContainer struct { callgraph callgraph.IGenerator cgScheduler callgraph.IScheduler cgStrategyFactory callgraphStrategy.IFactory + authenticator auth.IAuthenticator } func (cc *CliContainer) DebClient() client.IDebClient { @@ -146,6 +149,10 @@ func (cc *CliContainer) Fingerprinter() fingerprint.IFingerprint { return cc.fingerprinter } +func (cc *CliContainer) Authenticator() auth.IAuthenticator { + return cc.authenticator +} + func wireErr(err error) error { return fmt.Errorf("failed to wire with cli-container. Error %s", err) }