From 4a66939f06c878b4762d3fe7c8c5d52203770dfd Mon Sep 17 00:00:00 2001 From: filip Date: Wed, 11 Sep 2024 14:07:08 +0200 Subject: [PATCH] Adds initial testing --- internal/auth/auth.go | 87 +++++------------ internal/auth/auth_test.go | 103 +++++++++++++++++++- internal/auth/callback.go | 83 ++++++++++++++++ internal/auth/callback_test.go | 124 ++++++++++++++++++++++++ internal/auth/testdata/auth_mock.go | 82 ++++++++++++++++ internal/cmd/auth/auth_test.go | 2 +- internal/cmd/auth/login/login_test.go | 44 +++++++++ internal/cmd/auth/logout/logout_test.go | 44 +++++++++ internal/cmd/auth/token/token_test.go | 67 +++++++++++++ 9 files changed, 571 insertions(+), 65 deletions(-) create mode 100644 internal/auth/callback.go create mode 100644 internal/auth/callback_test.go create mode 100644 internal/auth/testdata/auth_mock.go create mode 100644 internal/cmd/auth/login/login_test.go create mode 100644 internal/cmd/auth/logout/logout_test.go create mode 100644 internal/cmd/auth/token/token_test.go diff --git a/internal/auth/auth.go b/internal/auth/auth.go index efb88c91..37c413a2 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -2,14 +2,8 @@ package auth import ( "context" - "fmt" "github.com/debricked/cli/internal/client" "github.com/zalando/go-keyring" - "log" - "net/http" - "os/exec" - "runtime" - "golang.org/x/oauth2" ) @@ -19,17 +13,23 @@ type IAuthenticator interface { Token() (*oauth2.Token, error) } -type Authenticator struct { - SecretClient ISecretClient - OAuthConfig *oauth2.Config -} - 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) +} // Wrapping interface for config to enable mocking + +type Authenticator struct { + SecretClient ISecretClient + OAuthConfig IOAuthConfig + AuthWebHelper IAuthWebHelper +} + type DebrickedSecretClient struct { User string } @@ -61,6 +61,7 @@ func NewDebrickedAuthenticator(client client.IDebClient) Authenticator { RedirectURL: "http://localhost:9096/callback", Scopes: []string{"select", "profile", "basicRepo"}, }, + AuthWebHelper: NewAuthWebHelper(), } } @@ -82,6 +83,7 @@ func (a Authenticator) Token() (*oauth2.Token, error) { if err != nil { return nil, err } + return &oauth2.Token{ RefreshToken: refreshToken, TokenType: "jwt", @@ -89,38 +91,21 @@ func (a Authenticator) Token() (*oauth2.Token, error) { }, nil } -func (a Authenticator) callback(state string) string { - code := make(chan string) - defer close(code) - server := &http.Server{Addr: ":9096"} // Start the server in a goroutine - go func() { - if err := server.ListenAndServe(); err != http.ErrServerClosed { - log.Fatalf("HTTP server error: %v", err) - } - }() - - defer server.Shutdown( - context.Background(), - ) // Ensure the server is shut down when we're done - http.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.") - }) - authCode := <-code // Wait for the authorization code - - return authCode +func (a Authenticator) saveToken(token *oauth2.Token) error { + err := a.SecretClient.Set("DebrickedRefreshToken", token.RefreshToken) + if err != nil { + return err + } + err = a.SecretClient.Set("DebrickedAccessToken", token.AccessToken) + + return err } func (a Authenticator) exchange(authCode, codeVerifier string) (*oauth2.Token, error) { + return a.OAuthConfig.Exchange( context.Background(), authCode, - oauth2.SetAuthURLParam("client_id", a.OAuthConfig.ClientID), oauth2.VerifierOption(codeVerifier), ) @@ -129,41 +114,21 @@ func (a Authenticator) exchange(authCode, codeVerifier string) (*oauth2.Token, e func (a Authenticator) Authenticate() error { state := oauth2.GenerateVerifier() codeVerifier := oauth2.GenerateVerifier() - authURL := a.OAuthConfig.AuthCodeURL( state, oauth2.S256ChallengeOption(codeVerifier), ) - err := openBrowser(authURL) + err := a.AuthWebHelper.OpenURL(authURL) if err != nil { - log.Fatal("Could not open browser:", err) + return err } - authCode := a.callback(state) + authCode := a.AuthWebHelper.Callback(state) token, err := a.exchange(authCode, codeVerifier) if err != nil { return err } - a.SecretClient.Set("DebrickedRefreshToken", token.RefreshToken) - a.SecretClient.Set("DebrickedAccessToken", token.AccessToken) - return nil -} - -func openBrowser(url string) error { - var cmd string - var args []string - - switch runtime.GOOS { - case "windows": - cmd = "cmd" - args = []string{"/c", "start"} - case "darwin": - cmd = "open" - default: // "linux", "freebsd", "openbsd", "netbsd" - cmd = "xdg-open" - } - args = append(args, url) - return exec.Command(cmd, args...).Start() + return a.saveToken(token) } diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index a2c74afc..31b20f96 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -3,11 +3,108 @@ package auth import ( "testing" - "github.com/debricked/cli/internal/client/testdata" + "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" ) -func TestNewGeneration(t *testing.T) { - res := NewDebrickedAuthenticator(testdata.NewDebClientMock()) +func TestNewAuthenticator(t *testing.T) { + res := NewDebrickedAuthenticator(clientTestdata.NewDebClientMock()) assert.NotNil(t, res) } + +func TestSecretClientSet(t *testing.T) { + 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) { + 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) { + 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 TestMockedSaveToken(t *testing.T) { + authenticator := Authenticator{ + SecretClient: testdata.MockSecretClient{}, + OAuthConfig: nil, + } + token := &oauth2.Token{ + RefreshToken: "refreshToken", + AccessToken: "accessToken", + } + err := authenticator.saveToken(token) + + assert.NoError(t, err) +} + +func TestMockedToken(t *testing.T) { + authenticator := Authenticator{ + SecretClient: testdata.MockSecretClient{}, + OAuthConfig: nil, + } + token, err := authenticator.Token() + + assert.NoError(t, err) + assert.Equal(t, token.TokenType, "jwt") + assert.Equal(t, token.RefreshToken, "token") + assert.Equal(t, token.AccessToken, "token") +} + +func TestMockedAuthenticate(t *testing.T) { + authenticator := Authenticator{ + SecretClient: testdata.MockSecretClient{}, + OAuthConfig: testdata.MockOAuthConfig{}, + AuthWebHelper: testdata.MockAuthWebHelper{}, + } + err := authenticator.Authenticate() + + assert.NoError(t, err) +} diff --git a/internal/auth/callback.go b/internal/auth/callback.go new file mode 100644 index 00000000..40d99d4f --- /dev/null +++ b/internal/auth/callback.go @@ -0,0 +1,83 @@ +package auth + +import ( + "context" + "fmt" + "log" + "net/http" + "os/exec" + "runtime" + "time" +) + +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 server.Shutdown( + context.Background(), + ) + authCode := <-code // Wait for the authorization code + + return authCode +} + +func (awh AuthWebHelper) openBrowserCmd(runtimeOS, url string) *exec.Cmd { + var cmd string + var args []string + switch runtimeOS { + case "windows": + cmd = "cmd" + args = []string{"/c", "start"} + case "darwin": + cmd = "open" + default: // "linux", "freebsd", "openbsd", "netbsd" + cmd = "xdg-open" + } + args = append(args, url) + + return exec.Command(cmd, args...) +} + +func (awh AuthWebHelper) OpenURL(authURL string) error { + openCmd := awh.openBrowserCmd(runtime.GOOS, authURL) + + return openCmd.Start() +} diff --git a/internal/auth/callback_test.go b/internal/auth/callback_test.go new file mode 100644 index 00000000..e8915ae0 --- /dev/null +++ b/internal/auth/callback_test.go @@ -0,0 +1,124 @@ +package auth + +import ( + "context" + "fmt" + "net/http" + "os/exec" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestCallback(t *testing.T) { + awh := NewAuthWebHelper() + testState := "test_state" + + 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 resp.Body.Close() + + 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() + testState := "test_state" + + 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"} + go server.ListenAndServe() + defer server.Shutdown(context.Background()) + + awh := AuthWebHelper{} + testState := "test_state" + + 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: + if err == nil { + t.Error("Expected an error due to server already running, but got none") + } + case <-time.After(2 * time.Second): + t.Fatal("Test timed out") + } +} + +func TestOpenBrowserCmd(t *testing.T) { + a := NewAuthWebHelper() + cases := []struct { + runtimeOS string + expectedCmd *exec.Cmd + }{ + { + runtimeOS: "darwin", + expectedCmd: exec.Command("open", "url"), + }, + { + runtimeOS: "linux", + expectedCmd: exec.Command("xdg-open", "url"), + }, + { + runtimeOS: "windows", + expectedCmd: exec.Command("cmd", "/c", "start", "url"), + }, + } + + for _, c := range cases { + t.Run(c.runtimeOS, func(t *testing.T) { + authCmd := a.openBrowserCmd(c.runtimeOS, "url") + assert.Equal(t, c.expectedCmd.Args, authCmd.Args) + }) + } +} diff --git a/internal/auth/testdata/auth_mock.go b/internal/auth/testdata/auth_mock.go new file mode 100644 index 00000000..9e496e00 --- /dev/null +++ b/internal/auth/testdata/auth_mock.go @@ -0,0 +1,82 @@ +package testdata + +import ( + "context" + + "golang.org/x/oauth2" +) + +type MockSecretClient struct{} + +func (msc MockSecretClient) Set(service, secret string) error { + return nil +} + +func (msc MockSecretClient) Get(service string) (string, error) { + return "token", nil +} + +func (msc MockSecretClient) Delete(service string) error { + return nil +} + +type MockError struct{} + +func (me MockError) Error() string { + return "MockError!" +} + +type MockAuthenticator struct{} + +type ErrorMockAuthenticator struct{} + +type MockOAuthConfig struct{} + +type MockAuthWebHelper 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 (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" +} diff --git a/internal/cmd/auth/auth_test.go b/internal/cmd/auth/auth_test.go index 4cb4114a..b10f08f6 100644 --- a/internal/cmd/auth/auth_test.go +++ b/internal/cmd/auth/auth_test.go @@ -8,7 +8,7 @@ import ( "github.com/stretchr/testify/assert" ) -func TestNewFilesCmd(t *testing.T) { +func TestNewAuthCmd(t *testing.T) { token := "token" deb_client := client.NewDebClient(&token, nil) authenticator := auth.NewDebrickedAuthenticator(deb_client) 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_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_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) +}