...

Source file src/edge-infra.dev/pkg/sds/emergencyaccess/emulatorsvc/emulatorsvc_test.go

Documentation: edge-infra.dev/pkg/sds/emergencyaccess/emulatorsvc

     1  package emulatorsvc
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"encoding/json"
     7  	"fmt"
     8  	"io"
     9  	"net/http"
    10  	"net/http/httptest"
    11  	"net/url"
    12  	"os"
    13  	"reflect"
    14  	"regexp"
    15  	"testing"
    16  	"time"
    17  
    18  	"github.com/stretchr/testify/assert"
    19  	"golang.org/x/oauth2"
    20  
    21  	"edge-infra.dev/pkg/lib/fog"
    22  	"edge-infra.dev/pkg/sds/emergencyaccess/eaconst"
    23  	"edge-infra.dev/pkg/sds/emergencyaccess/msgdata"
    24  	"edge-infra.dev/pkg/sds/emergencyaccess/types"
    25  )
    26  
    27  const apiV2 = "/api/v2"
    28  const https = "https://"
    29  
    30  var (
    31  	defaultAccessToken           = "accessToken"
    32  	defaultSessionTarget         = types.Target{Projectid: "", Bannerid: "bannerID", Storeid: "storeID", Terminalid: "terminalID"}
    33  	defaultSessionResolvedTarget = types.Target{
    34  		Projectid:  "project-UUID",
    35  		Bannerid:   "banner-UUID",
    36  		Storeid:    "store-UUID",
    37  		Terminalid: "terminal-UUID",
    38  	}
    39  	testSessionID           string
    40  	defaultResponseDataJSON = []byte(`
    41  {
    42  	"type": "Output",
    43  	"exitCode": 0,
    44  	"output": "hello\n",
    45  	"timestamp": "01-01-2023 00:00:00",
    46  	"duration": 0.1
    47  }`)
    48  	defaultAttrMap = map[string]string{
    49  		"bannerId":             "banner",
    50  		"storeId":              "store",
    51  		"terminalId":           "terminal",
    52  		"sessionId":            "orderingKey",
    53  		"identity":             "identity",
    54  		"version":              "1.0",
    55  		"signature":            "signature",
    56  		"request-message-uuid": "test",
    57  		"commandId":            "testID",
    58  	}
    59  )
    60  
    61  type tHelper interface {
    62  	Helper()
    63  }
    64  
    65  func EqualError(message string) assert.ErrorAssertionFunc {
    66  	return func(t assert.TestingT, err error, i ...interface{}) bool {
    67  		if tt, ok := t.(tHelper); ok {
    68  			tt.Helper()
    69  		}
    70  		return assert.EqualError(t, err, message, i...)
    71  	}
    72  }
    73  
    74  // MatchesError does a regex assertion against the error message
    75  func MatchesError(message string) assert.ErrorAssertionFunc {
    76  	return func(t assert.TestingT, err error, i ...interface{}) bool {
    77  		if tt, ok := t.(tHelper); ok {
    78  			tt.Helper()
    79  		}
    80  
    81  		return assert.Regexp(t, regexp.MustCompile(message), err.Error(), i...)
    82  	}
    83  }
    84  
    85  func TestMain(m *testing.M) {
    86  	// Create temp dir where files get created on initialisation
    87  	dir, err := os.MkdirTemp("", "")
    88  	if err != nil {
    89  		panic(err)
    90  	}
    91  	os.Setenv(envFilePathDir, dir)
    92  
    93  	m.Run()
    94  
    95  	// Remove temporary directory
    96  	os.Unsetenv(envFilePathDir)
    97  	err = os.RemoveAll(dir)
    98  	if err != nil {
    99  		panic(err)
   100  	}
   101  }
   102  
   103  // Test Servers TODO: move these to separate file?
   104  // Tests the startSession query. asserts the payload target is the same as the defaultSessionTarget
   105  // Returns the resolved target UUID's in the expected headers
   106  func startSessionServer(t *testing.T) *httptest.Server {
   107  	mux := http.NewServeMux()
   108  	mux.HandleFunc("/startSession", func(w http.ResponseWriter, r *http.Request) {
   109  		data, err := io.ReadAll(r.Body)
   110  		assert.NoError(t, err)
   111  		var payload types.StartSessionPayload
   112  		err = json.Unmarshal(data, &payload)
   113  		assert.NoError(t, err)
   114  		assert.EqualValues(t, defaultSessionTarget, payload.Target)
   115  
   116  		w.Header().Add("X-EA-ProjectID", defaultSessionResolvedTarget.Projectid)
   117  		w.Header().Add("X-EA-BannerID", defaultSessionResolvedTarget.Bannerid)
   118  		w.Header().Add("X-EA-StoreID", defaultSessionResolvedTarget.Storeid)
   119  		w.Header().Add("X-EA-TerminalID", defaultSessionResolvedTarget.Terminalid)
   120  	})
   121  	server := httptest.NewServer(mux)
   122  	return server
   123  }
   124  
   125  // checks no error is returned on connection and the target in the payload matches
   126  func TestConnect(t *testing.T) {
   127  	ctx, cancel := context.WithCancel(context.Background())
   128  	defer cancel()
   129  	// tests are in the server endpoint
   130  	server := startSessionServer(t)
   131  	defer server.Close()
   132  	t.Setenv("RCLI_GATEWAY_HOST", server.URL)
   133  	es, err := New(ctx, Config{})
   134  	assert.NoError(t, err)
   135  	es.idToken = &oauth2.Token{
   136  		AccessToken: defaultAccessToken,
   137  		Expiry:      time.Now().Add(24 * time.Hour),
   138  	}
   139  	err = es.Connect(ctx, "", "bannerID", "storeID", "terminalID")
   140  	assert.NoError(t, err)
   141  	assert.Equal(t, defaultSessionResolvedTarget, es.session.target)
   142  }
   143  
   144  // TODO: This should write back an error that apierrorhandler.ParseJSONAPIError() can parse
   145  func connectionFailureServer(status int) *httptest.Server {
   146  	mux := http.NewServeMux()
   147  	mux.HandleFunc("/empty/startSession", func(w http.ResponseWriter, _ *http.Request) {
   148  		w.WriteHeader(status)
   149  	})
   150  	mux.HandleFunc("/json/startSession", func(w http.ResponseWriter, _ *http.Request) {
   151  		w.WriteHeader(status)
   152  		_, _ = w.Write([]byte(`
   153  		{
   154  			"errorCode": 61111,
   155  			"errorMessage": "Bad things happen",
   156  			"details": ["lots", "of", "bad", "things"]
   157  		}
   158  		`))
   159  	})
   160  	server := httptest.NewServer(mux)
   161  	return server
   162  }
   163  
   164  func TestConnectFail(t *testing.T) {
   165  	cases := map[string]struct {
   166  		path      string
   167  		errAssert assert.ErrorAssertionFunc
   168  	}{
   169  		"Error on non-OK": {
   170  			path:      "/empty/",
   171  			errAssert: MatchesError(`error calling startSession API \(http://127.0.0.1:.*/empty/startSession\), status \(403 Forbidden\)`),
   172  		},
   173  		"APIError response": {
   174  			path:      "/json/",
   175  			errAssert: EqualError("61111: Bad things happen"),
   176  		},
   177  	}
   178  
   179  	for name, tc := range cases {
   180  		t.Run(name, func(t *testing.T) {
   181  			ctx, cancel := context.WithCancel(context.Background())
   182  			defer cancel()
   183  
   184  			server := connectionFailureServer(403)
   185  			defer server.Close()
   186  
   187  			host := server.URL
   188  			if tc.path != "" {
   189  				host += tc.path
   190  			}
   191  
   192  			t.Setenv("RCLI_GATEWAY_HOST", host)
   193  			es, err := New(ctx, Config{})
   194  			assert.NoError(t, err)
   195  
   196  			es.idToken = &oauth2.Token{
   197  				AccessToken: defaultAccessToken,
   198  			}
   199  			err = es.Connect(ctx, "", "bannerID", "storeID", "terminalID")
   200  			tc.errAssert(t, err)
   201  		})
   202  	}
   203  }
   204  
   205  // checks whether send delivers a correctly parsed send payload to
   206  // a mock endpoint
   207  func TestSend(t *testing.T) {
   208  	ctx, cancel := context.WithCancel(context.Background())
   209  	defer cancel()
   210  	server := sendCommandServer(t)
   211  	defer server.Close()
   212  	t.Setenv("RCLI_GATEWAY_HOST", server.URL)
   213  	es, err := New(ctx, Config{})
   214  	assert.NoError(t, err)
   215  	es.idToken = &oauth2.Token{
   216  		AccessToken: defaultAccessToken,
   217  	}
   218  	err = es.Connect(ctx, "", "bannerID", "storeID", "terminalID")
   219  	// testSessionID is checked by sendCommandServer
   220  	testSessionID = es.session.ID
   221  	assert.NoError(t, err)
   222  	commandID, err := es.Send("test")
   223  	assert.NoError(t, err)
   224  
   225  	assert.Equal(t, "abcd", commandID)
   226  }
   227  
   228  func TestDarkmode(t *testing.T) {
   229  	tests := map[string]struct {
   230  		darkmode bool
   231  	}{
   232  		"Darkmode True": {
   233  			darkmode: true,
   234  		},
   235  		"Darkmode False": {
   236  			darkmode: false,
   237  		},
   238  	}
   239  	for name, tc := range tests {
   240  		t.Run(name, func(t *testing.T) {
   241  			ctx, cancel := context.WithCancel(context.Background())
   242  			defer cancel()
   243  			// set up the test server
   244  			server := sendCommandServer(t, assertDarkmode(tc.darkmode))
   245  			defer server.Close()
   246  			t.Setenv("RCLI_GATEWAY_HOST", server.URL)
   247  			es, err := New(ctx, Config{})
   248  			assert.NoError(t, err)
   249  			es.idToken = &oauth2.Token{
   250  				AccessToken: defaultAccessToken,
   251  				Expiry:      time.Now().Add(24 * time.Hour),
   252  			}
   253  			// set darkmode
   254  			es.SetDarkmode(tc.darkmode)
   255  			err = es.Connect(ctx, "", "bannerID", "storeID", "terminalID")
   256  			// testSessionID is checked by sendCommandServer
   257  			testSessionID = es.session.ID
   258  			assert.NoError(t, err)
   259  			_, err = es.Send("test")
   260  			assert.NoError(t, err)
   261  		})
   262  	}
   263  }
   264  
   265  // tests whether the context gets cancelled and the displaychannel is closed
   266  func TestEnd(t *testing.T) {
   267  	ctx, cancel := context.WithCancel(context.Background())
   268  	defer cancel()
   269  	server := endSessionServer(t)
   270  	defer server.Close()
   271  	t.Setenv("RCLI_GATEWAY_HOST", server.URL)
   272  	es, err := New(ctx, Config{})
   273  	es.idToken = &oauth2.Token{
   274  		AccessToken: defaultAccessToken,
   275  	}
   276  	assert.NoError(t, err)
   277  	err = es.Connect(ctx, "projectID", "bannerID", "storeID", "terminalID")
   278  	assert.NoError(t, err)
   279  	testSessionID = es.session.ID
   280  	err = es.End()
   281  	assert.NoError(t, err)
   282  	done := <-es.session.context.Done()
   283  	assert.NotNil(t, done)
   284  }
   285  
   286  func TestSetGatewayURLs(t *testing.T) {
   287  	t.Parallel()
   288  
   289  	ctx, cancel := context.WithCancel(context.Background())
   290  	defer cancel()
   291  
   292  	log := fog.New(fog.To(io.Discard))
   293  	ctx = fog.IntoContext(ctx, log)
   294  
   295  	// Precondition, env var behaviour is tested in TestSetGatewayURLsEnvVar
   296  	_, ok := os.LookupEnv(envGatewayHost)
   297  	assert.False(t, ok, "Test Requires %s to not be set", envGatewayHost)
   298  
   299  	host := "dev1.edge-preprod.dev"
   300  
   301  	tests := map[string]struct {
   302  		uri string
   303  	}{
   304  		"Simple": {
   305  			uri: https + host + apiV2,
   306  		},
   307  		"Trailing Slash": {
   308  			uri: https + host + "/api/v2/",
   309  		},
   310  		"Just host": {
   311  			uri: https + host,
   312  		},
   313  		"Different path": {
   314  			uri: https + host + "/different/path",
   315  		},
   316  	}
   317  
   318  	for name, tc := range tests {
   319  		tc := tc
   320  		t.Run(name, func(t *testing.T) {
   321  			t.Parallel()
   322  
   323  			es := EmulatorService{
   324  				config: &Config{
   325  					Profile: Profile{
   326  						API: tc.uri,
   327  					},
   328  				},
   329  			}
   330  
   331  			// Test
   332  			err := es.setGatewayURLs(ctx)
   333  			assert.NoError(t, err)
   334  			expectedValue := gatewayURLs{
   335  				send:  &url.URL{Host: host, Path: "/api/ea/sendCommand", Scheme: "https"},
   336  				start: &url.URL{Host: host, Path: "/api/ea/startSession", Scheme: "https"},
   337  				end:   &url.URL{Host: host, Path: "/api/ea/endSession", Scheme: "https"},
   338  			}
   339  			assert.EqualValues(t, &expectedValue, es.gatewayURLs)
   340  		})
   341  	}
   342  }
   343  
   344  // checks the gatewayURLs struct is set correctly
   345  func TestSetGatewayURLsEnvVar(t *testing.T) {
   346  	tests := map[string]struct {
   347  		envVar string
   348  		expVal gatewayURLs
   349  	}{
   350  		"Simple": {
   351  			envVar: "https://testhostURL",
   352  			expVal: gatewayURLs{
   353  				start: &url.URL{Host: "testhostURL", Path: "/startSession", Scheme: "https"},
   354  				end:   &url.URL{Host: "testhostURL", Path: "/endSession", Scheme: "https"},
   355  				send:  &url.URL{Host: "testhostURL", Path: "/sendCommand", Scheme: "https"},
   356  			},
   357  		},
   358  		"Simple with port": {
   359  			envVar: "https://testhostURL:8080",
   360  			expVal: gatewayURLs{
   361  				start: &url.URL{Host: "testhostURL:8080", Path: "/startSession", Scheme: "https"},
   362  				end:   &url.URL{Host: "testhostURL:8080", Path: "/endSession", Scheme: "https"},
   363  				send:  &url.URL{Host: "testhostURL:8080", Path: "/sendCommand", Scheme: "https"},
   364  			},
   365  		},
   366  		"With Path": {
   367  			envVar: "https://testhostURL/abcd/", // Note the trailing slash is required for correct path setting
   368  			expVal: gatewayURLs{
   369  				start: &url.URL{Host: "testhostURL", Path: "/abcd/startSession", Scheme: "https"},
   370  				end:   &url.URL{Host: "testhostURL", Path: "/abcd/endSession", Scheme: "https"},
   371  				send:  &url.URL{Host: "testhostURL", Path: "/abcd/sendCommand", Scheme: "https"},
   372  			},
   373  		},
   374  		"With Port and Path": {
   375  			envVar: "https://testhostURL:8080/abcd/",
   376  			expVal: gatewayURLs{
   377  				start: &url.URL{Host: "testhostURL:8080", Path: "/abcd/startSession", Scheme: "https"},
   378  				end:   &url.URL{Host: "testhostURL:8080", Path: "/abcd/endSession", Scheme: "https"},
   379  				send:  &url.URL{Host: "testhostURL:8080", Path: "/abcd/sendCommand", Scheme: "https"},
   380  			},
   381  		},
   382  
   383  		// The following tests are just used to document the current behaviour of
   384  		// different forms of the RCLI_GATEWAY_HOST env var. There are no requirements
   385  		// that the behaviour remains the same
   386  		"Missing scheme": {
   387  			envVar: "testhostURL:8080",
   388  			// Obviously these are invalid, but having the test makes it explicit
   389  			// that this form of the env var is not supported.
   390  			expVal: gatewayURLs{
   391  				start: &url.URL{Host: "", Path: "/startSession", Scheme: "testhosturl"},
   392  				end:   &url.URL{Host: "", Path: "/endSession", Scheme: "testhosturl"},
   393  				send:  &url.URL{Host: "", Path: "/sendCommand", Scheme: "testhosturl"},
   394  			},
   395  		},
   396  		"Just host": {
   397  			envVar: "testhostURL",
   398  			// This uses the value from the configured API endpoint
   399  			expVal: gatewayURLs{
   400  				send:  &url.URL{Host: "dev1.edge-preprod.dev", Path: "/api/ea/sendCommand", Scheme: "https"},
   401  				start: &url.URL{Host: "dev1.edge-preprod.dev", Path: "/api/ea/startSession", Scheme: "https"},
   402  				end:   &url.URL{Host: "dev1.edge-preprod.dev", Path: "/api/ea/endSession", Scheme: "https"},
   403  			},
   404  		},
   405  		"Missing Trailing slash": {
   406  			// Test documents a trailing slash is required for correctly setting path segment
   407  			envVar: "https://testhostURL/abcd",
   408  			expVal: gatewayURLs{
   409  				start: &url.URL{Host: "testhostURL", Path: "/startSession", Scheme: "https"},
   410  				end:   &url.URL{Host: "testhostURL", Path: "/endSession", Scheme: "https"},
   411  				send:  &url.URL{Host: "testhostURL", Path: "/sendCommand", Scheme: "https"},
   412  			},
   413  		},
   414  	}
   415  
   416  	for name, tc := range tests {
   417  		tc := tc
   418  		t.Run(name, func(t *testing.T) {
   419  			ctx, cancel := context.WithCancel(context.Background())
   420  			defer cancel()
   421  
   422  			t.Setenv(envGatewayHost, tc.envVar)
   423  
   424  			es := EmulatorService{config: &Config{Profile: Profile{API: "https://dev1.edge-preprod.dev/api/v2/"}}}
   425  
   426  			err := es.setGatewayURLs(ctx)
   427  			assert.NoError(t, err)
   428  
   429  			assert.EqualValues(t, &tc.expVal, es.gatewayURLs)
   430  		})
   431  	}
   432  }
   433  
   434  // checks any incoming data on the request gets posted to the display channel
   435  func TestPostToDisplayChan(t *testing.T) {
   436  	ctx, cancel := context.WithCancel(context.Background())
   437  	defer cancel()
   438  	server := postToDisplayChannelServer(t)
   439  	defer server.Close()
   440  	t.Setenv("RCLI_GATEWAY_HOST", server.URL)
   441  
   442  	es, err := New(ctx, Config{})
   443  	assert.NoError(t, err)
   444  	es.idToken = &oauth2.Token{
   445  		AccessToken: defaultAccessToken,
   446  	}
   447  	assert.NoError(t, err)
   448  	// need to run Connect to initialise the display channel and post to the request buffer
   449  	err = es.Connect(ctx, "projectID", "bannerID", "storeID", "terminalID")
   450  	assert.NoError(t, err)
   451  	msg := <-es.dispChan
   452  	// create the expected output for the test
   453  	expectedMsg, err := msgdata.NewCommandResponse(defaultResponseDataJSON, defaultAttrMap)
   454  	assert.NoError(t, err)
   455  	assert.EqualValues(t, expectedMsg, msg)
   456  }
   457  
   458  func TestGetSessionContext(t *testing.T) {
   459  	ctx, cancel := context.WithCancel(context.Background())
   460  	defer cancel()
   461  	server := startSessionServer(t)
   462  	defer server.Close()
   463  	t.Setenv("RCLI_GATEWAY_HOST", server.URL)
   464  
   465  	es, err := New(ctx, Config{})
   466  	assert.NoError(t, err)
   467  	es.idToken = &oauth2.Token{
   468  		AccessToken: defaultAccessToken,
   469  	}
   470  	assert.NoError(t, err)
   471  	err = es.Connect(ctx, "", "bannerID", "storeID", "terminalID")
   472  	assert.NoError(t, err)
   473  	expectedSessionID := es.session.ID
   474  	assert.Equal(t, expectedSessionID, es.GetSessionContext().Value(sessionID))
   475  }
   476  
   477  func assertDarkmode(val bool) payloadAssertions {
   478  	if val {
   479  		return func(t *testing.T, sp types.SendPayload) {
   480  			assert.True(t, sp.AuthDetails.DarkMode)
   481  		}
   482  	}
   483  	return func(t *testing.T, sp types.SendPayload) {
   484  		assert.False(t, sp.AuthDetails.DarkMode)
   485  	}
   486  }
   487  
   488  type payloadAssertions func(*testing.T, types.SendPayload)
   489  
   490  // Tests the sendCommand query. Returns resolved target UUID's in StartSession's
   491  // return headers. Assert the payload target is the same as defaultSessionResolvedTarget,
   492  // command is test and sessionID is the same as defaultSessionID. Any additional asserts on the SendPayload can
   493  // be passed as options via the payloadAssertions type if required.
   494  func sendCommandServer(t *testing.T, asserts ...payloadAssertions) *httptest.Server {
   495  	mux := http.NewServeMux()
   496  	mux.HandleFunc("/startSession", func(w http.ResponseWriter, _ *http.Request) {
   497  		w.Header().Add("X-EA-ProjectID", defaultSessionResolvedTarget.Projectid)
   498  		w.Header().Add("X-EA-BannerID", defaultSessionResolvedTarget.Bannerid)
   499  		w.Header().Add("X-EA-StoreID", defaultSessionResolvedTarget.Storeid)
   500  		w.Header().Add("X-EA-TerminalID", defaultSessionResolvedTarget.Terminalid)
   501  	})
   502  	mux.HandleFunc("/sendCommand", func(w http.ResponseWriter, r *http.Request) {
   503  		data, err := io.ReadAll(r.Body)
   504  		assert.NoError(t, err)
   505  		var payload types.SendPayload
   506  		err = json.Unmarshal(data, &payload)
   507  		assert.NoError(t, err)
   508  		assert.EqualValues(t, defaultSessionResolvedTarget, payload.Target)
   509  		assert.Equal(t, "test", payload.Command)
   510  		assert.Equal(t, payload.SessionID, testSessionID)
   511  		for _, assert := range asserts {
   512  			assert(t, payload)
   513  		}
   514  
   515  		w.Header().Add(`X-Correlation-ID`, "abcd")
   516  	})
   517  	server := httptest.NewServer(mux)
   518  	return server
   519  }
   520  
   521  // adds a connectionpayload with the defaultResponseDataJSON and defaultAttrMap to a response buffer one second after connection
   522  func postToDisplayChannelServer(t *testing.T) *httptest.Server {
   523  	// channel := make(chan string)
   524  	mux := http.NewServeMux()
   525  	mux.HandleFunc("/startSession", func(w http.ResponseWriter, _ *http.Request) {
   526  		w.(http.Flusher).Flush()
   527  		// wait for 1 second so the post to display channel is up and running.
   528  		time.Sleep(1 * time.Second)
   529  		msg, err := msgdata.NewCommandResponse(defaultResponseDataJSON, defaultAttrMap)
   530  		assert.NoError(t, err)
   531  		resp := types.ConnectionPayload{Message: msg}
   532  		bytes, err := json.Marshal(resp)
   533  		assert.NoError(t, err)
   534  		_, err = w.Write(bytes)
   535  		assert.NoError(t, err)
   536  	})
   537  	server := httptest.NewServer(mux)
   538  	return server
   539  }
   540  
   541  // tests the payload is correctly formed and compares the sessionid in the payload to testSessionID
   542  func endSessionServer(t *testing.T) *httptest.Server {
   543  	mux := http.NewServeMux()
   544  	mux.HandleFunc("/startSession", func(_ http.ResponseWriter, _ *http.Request) {
   545  	})
   546  	mux.HandleFunc("/sendCommand", func(_ http.ResponseWriter, _ *http.Request) {
   547  	})
   548  	mux.HandleFunc("/endSession", func(_ http.ResponseWriter, r *http.Request) {
   549  		data, err := io.ReadAll(r.Body)
   550  		assert.NoError(t, err)
   551  		var payload types.EndSessionPayload
   552  		err = json.Unmarshal(data, &payload)
   553  		assert.NoError(t, err)
   554  		assert.Equal(t, testSessionID, payload.SessionID)
   555  	})
   556  	server := httptest.NewServer(mux)
   557  	return server
   558  }
   559  
   560  const (
   561  	validQueryF    = "{\"query\":\"mutation($organization:String!$password:String!$username:String!){login(username: $username, password: $password, organization: $organization){token,banners{bannerEdgeId}}}\",\"variables\":{\"organization\":\"%s\",\"password\":\"%s\",\"username\":\"%s\"}}\n"
   562  	validResponseF = `{"login": {"token": "%s"}}`
   563  
   564  	validToken   = "ewogICJhbGciOiAiSFM1MTIiLAogICJ0eXAiOiAiSldUIgp9.ewogICJhdXRoUHJvdmlkZXIiOiAiYnNsIiwKICAiZW1haWwiOiAiYWNjb3VudCIsCiAgIm9yZ2FuaXphdGlvbiI6ICJvcmdhbml6YXRpb24iLAogICJyZWZyZXNoVG9rZW4iOiAiIiwKICAicm9sZXMiOiBbCiAgICAiUk9MRSIKICBdLAogICJ0b2tlbiI6ICJld29nSUNKMGVYQWlPaUFpU2xkVUlpd0tJQ0FpWVd4bklqb2dJa1ZUTWpVMklncDkuZXdvZ0lDSnRkR2dpT2lCYkNpQWdJQ0FpY0dGemMzZHZjbVFpQ2lBZ1hTd0tJQ0FpYzNWaUlqb2dJbUZqWTI5MWJuUWlMQW9nSUNKdVltWWlPaUF4TnpFeE1UQXlOamcyTEFvZ0lDSnZjbWNpT2lBaWIzSm5ZVzVwZW1GMGFXOXVJaXdLSUNBaWFYTnpJam9nSW1semMzVmxjaUlzQ2lBZ0luSnNjeUk2SUNKbFNuaE9hVGhyVG1kRVFVMUNUamt5VG1KU1oydFdWM2RyU25wSmJVeFFMMUZxUVdZMFJGZGhNV1Y2UVdoek5tdENVbGxoU25oT2RHbG9NMDlHUnpKSGNHVk5XVW8zU0RaVVRtNUJkU3QxUTFnd1psWjRVMHcxVEhNeWNtMUdORk15ZFhkTVJqUXlPR3BSTlRWdFEyaE5ZWGs1Y0U0M2JWbElaMEU5SWl3S0lDQWlaWGh3SWpvZ01UY3hNVEV3TXpVNE5pd0tJQ0FpYVdGMElqb2dNVGN4TVRFd01qWTROaXdLSUNBaWFuUnBJam9nSWpNeE1XRXdaamcwTFRsa05USXROREU0TUMxaVpHUXdMV1psWlRoak5UaGpZemRsTUNJS2ZRLlUybG5ibUYwZFhKbE9pQmhkRE5PZWtNM2RXbDNTelE0V1VVNWJTMVBRMUV6VFZGcFRVMVVabDlxUkY4d2EzUmFWaTAxV0ZKSWNHaGpPWFJTVURSSlJGVk1WVWhDUlhSMmVVMDRPRTQwV1RadE1rRTNXVXAzYlY5MVpYQTFWM0JEWnciLAogICJ1c2VybmFtZSI6ICJ1c2VyIgp9.signature" //nolint:gosec // it's not that interesting
   565  	invalidToken = "invalid"
   566  )
   567  
   568  type graphQLErr struct {
   569  	Message   string
   570  	Locations []struct {
   571  		Line   int
   572  		Column int
   573  	}
   574  }
   575  
   576  func startSessionReadCookieServer(t *testing.T, expected *http.Cookie) *httptest.Server {
   577  	mux := http.NewServeMux()
   578  	mux.HandleFunc("/startSession", func(w http.ResponseWriter, r *http.Request) {
   579  		cookie, err := r.Cookie("edge-session")
   580  		assert.NoError(t, err)
   581  		if reflect.DeepEqual(cookie, expected) {
   582  			w.WriteHeader(http.StatusOK)
   583  		} else {
   584  			w.WriteHeader(http.StatusForbidden)
   585  		}
   586  	})
   587  	server := httptest.NewServer(mux)
   588  	return server
   589  }
   590  
   591  func edgeAPIMockServer(t *testing.T, expQuery string, token string) *httptest.Server {
   592  	type output struct {
   593  		Data   *json.RawMessage `json:"data"`
   594  		Errors []graphQLErr     `json:"errors,omitempty"`
   595  	}
   596  
   597  	generateErr := func(key string) graphQLErr {
   598  		message := fmt.Sprintf("Field \"login\" argument \"%s\" of type \"String!\" is required but not provided.", key)
   599  		return graphQLErr{
   600  			Message: message,
   601  			Locations: []struct {
   602  				Line   int
   603  				Column int
   604  			}{
   605  				{Line: 2, Column: 3},
   606  			},
   607  		}
   608  	}
   609  
   610  	mux := http.NewServeMux()
   611  	mux.HandleFunc(apiV2, func(w http.ResponseWriter, r *http.Request) {
   612  		var err error
   613  
   614  		// Read body and ensure it's expected
   615  		data, err := io.ReadAll(r.Body)
   616  		assert.NoError(t, err)
   617  		assert.Equal(t, expQuery, string(data))
   618  
   619  		// Parse variables map
   620  		variables := bytes.SplitAfter(bytes.Split(data, []byte(`"variables":`))[1], []byte("}"))[0]
   621  		var m map[string]string
   622  		err = json.Unmarshal(variables, &m)
   623  		assert.NoError(t, err)
   624  
   625  		// Check for errors
   626  		var errs []graphQLErr
   627  		if m["username"] == "" {
   628  			errs = append(errs, generateErr("username"))
   629  		}
   630  		if m["password"] == "" {
   631  			errs = append(errs, generateErr("password"))
   632  		}
   633  		if m["organization"] == "" {
   634  			errs = append(errs, generateErr("organization"))
   635  		}
   636  
   637  		// Prepare output
   638  		var outputData *json.RawMessage
   639  		if len(errs) == 0 {
   640  			d := json.RawMessage(fmt.Sprintf(validResponseF, token))
   641  			outputData = &d
   642  		}
   643  		out := output{
   644  			Data:   outputData,
   645  			Errors: errs,
   646  		}
   647  		res, err := json.Marshal(out)
   648  		assert.NoError(t, err)
   649  
   650  		// Write response
   651  		http.SetCookie(w, &http.Cookie{
   652  			Name:  "edge-session",
   653  			Value: "MTcxMjIyMzUzOHxOd3dBTkVkWFRVOVhOelpDVEV4UVdqTmFXRlpOTkZoRU4xa3pTMUJLVkVJelQwMUJNa2hTUzA1UVRWaFJSbFJPVVZGR1VWRTNRa0U9fIUdYK4-Qw-mlW3KIOInztkT-v8RpJyoTiMNVUsiA9Qx",
   654  			Path:  "/", // Set to "/" for local test
   655  		})
   656  		w.Header().Add("content-type", "application/json")
   657  		w.WriteHeader(http.StatusOK)
   658  		_, err = w.Write(res)
   659  		assert.NoError(t, err)
   660  	})
   661  	server := httptest.NewServer(mux)
   662  	return server
   663  }
   664  
   665  func TestRetrieveIdentityJWT(t *testing.T) {
   666  	server := edgeAPIMockServer(t, fmt.Sprintf(validQueryF, "c", "b", "a"), validToken)
   667  	defer server.Close()
   668  
   669  	es, err := New(context.Background(), Config{
   670  		Profile: Profile{
   671  			Username:     "a",
   672  			Password:     "b",
   673  			Organization: "c",
   674  			API:          server.URL + apiV2,
   675  		},
   676  	})
   677  	assert.NoError(t, err)
   678  
   679  	err = es.RetrieveIdentity(context.Background())
   680  	assert.NoError(t, err)
   681  	assert.Equal(t, validToken, es.idToken.AccessToken)
   682  	assert.NotEmpty(t, es.userID)
   683  	assert.Equal(t, "Bearer", es.idToken.TokenType)
   684  	assert.NotNil(t, es.idToken.Expiry)
   685  }
   686  
   687  func TestProfileCookie(t *testing.T) {
   688  	// This test is similar to TestRetrieveIdentityCookie, however specifically
   689  	// applies for remotecli exec invocation rather than interactive remotecli
   690  
   691  	// setup
   692  	ctx, cancel := context.WithCancel(context.Background())
   693  	defer cancel()
   694  
   695  	expectedCookie := &http.Cookie{
   696  		Name:  "edge-session",
   697  		Value: "MTcxMjIyMzUzOHxOd3dBTkVkWFRVOVhOelpDVEV4UVdqTmFXRlpOTkZoRU4xa3pTMUJLVkVJelQwMUJNa2hTUzA1UVRWaFJSbFJPVVZGR1VWRTNRa0U9fIUdYK4-Qw-mlW3KIOInztkT-v8RpJyoTiMNVUsiA9Qx",
   698  		Path:  "", // When path is set to "/", it is returned to Jar as an empty field
   699  	}
   700  
   701  	startSeshServer := startSessionReadCookieServer(t, expectedCookie)
   702  	defer startSeshServer.Close()
   703  
   704  	t.Setenv("RCLI_GATEWAY_HOST", startSeshServer.URL)
   705  	api, err := url.Parse(startSeshServer.URL)
   706  	assert.NoError(t, err)
   707  
   708  	es, err := New(context.Background(), Config{
   709  		Profile: Profile{
   710  			Username:      "a",
   711  			Password:      "b",
   712  			Organization:  "c",
   713  			API:           api.String(),
   714  			SessionCookie: expectedCookie.String(),
   715  		},
   716  	})
   717  	assert.NoError(t, err)
   718  
   719  	// test
   720  
   721  	// Ensure cookie set in Profile Config was stored in Jar
   722  	u, err := url.Parse(startSeshServer.URL)
   723  	assert.NoError(t, err)
   724  	cookie := es.client.Jar.Cookies(u)[0]
   725  	assert.Equal(t, expectedCookie, cookie)
   726  
   727  	// Ensure cookie is passed to different request
   728  	err = es.Connect(ctx, "", "bannerID", "storeID", "terminalID")
   729  	assert.NoError(t, err)
   730  }
   731  
   732  func TestRetrieveIdentityCookie(t *testing.T) {
   733  	// setup
   734  	ctx, cancel := context.WithCancel(context.Background())
   735  	defer cancel()
   736  
   737  	expectedCookie := &http.Cookie{
   738  		Name:  "edge-session",
   739  		Value: "MTcxMjIyMzUzOHxOd3dBTkVkWFRVOVhOelpDVEV4UVdqTmFXRlpOTkZoRU4xa3pTMUJLVkVJelQwMUJNa2hTUzA1UVRWaFJSbFJPVVZGR1VWRTNRa0U9fIUdYK4-Qw-mlW3KIOInztkT-v8RpJyoTiMNVUsiA9Qx",
   740  		Path:  "", // When path is set to "/", it is returned to Jar as an empty field
   741  	}
   742  
   743  	startSeshServer := startSessionReadCookieServer(t, expectedCookie)
   744  	defer startSeshServer.Close()
   745  
   746  	apiServer := edgeAPIMockServer(t, fmt.Sprintf(validQueryF, "c", "b", "a"), validToken)
   747  	defer apiServer.Close()
   748  
   749  	t.Setenv("RCLI_GATEWAY_HOST", startSeshServer.URL)
   750  	api, err := url.Parse(apiServer.URL + "/api/v2")
   751  	assert.NoError(t, err)
   752  
   753  	es, err := New(context.Background(), Config{
   754  		Profile: Profile{
   755  			Username:     "a",
   756  			Password:     "b",
   757  			Organization: "c",
   758  			API:          api.String(),
   759  		},
   760  	})
   761  	assert.NoError(t, err)
   762  
   763  	// test
   764  	err = es.RetrieveIdentity(context.Background())
   765  	assert.NoError(t, err)
   766  
   767  	// Ensure cookie set in /api/v2 was stored in Jar
   768  	u, err := url.Parse(apiServer.URL)
   769  	assert.NoError(t, err)
   770  	cookie := es.client.Jar.Cookies(u)[0]
   771  	assert.Equal(t, expectedCookie, cookie)
   772  
   773  	// Ensure cookie is saved to the profile
   774  	assert.Equal(t, expectedCookie.String(), es.config.Profile.SessionCookie)
   775  
   776  	// Ensure cookie is passed to different request
   777  	err = es.Connect(ctx, "", "bannerID", "storeID", "terminalID")
   778  	assert.NoError(t, err)
   779  
   780  	// Ensure cookie is returned by env for remotecli exec
   781  	expEnv := []string{
   782  		fmt.Sprintf("RCLI_API_ENDPOINT=%s/api/v2", apiServer.URL),
   783  		"RCLI_COOKIE=edge-session=MTcxMjIyMzUzOHxOd3dBTkVkWFRVOVhOelpDVEV4UVdqTmFXRlpOTkZoRU4xa3pTMUJLVkVJelQwMUJNa2hTUzA1UVRWaFJSbFJPVVZGR1VWRTNRa0U9fIUdYK4-Qw-mlW3KIOInztkT-v8RpJyoTiMNVUsiA9Qx",
   784  	}
   785  	assert.Equal(t, expEnv, es.Env())
   786  }
   787  
   788  func TestRetrieveIdentityInvalid(t *testing.T) {
   789  	cases := map[string]struct {
   790  		config    Config
   791  		expQuery  string
   792  		errAssert assert.ErrorAssertionFunc
   793  	}{
   794  		"Missing Username Field": {
   795  			config: Config{
   796  				Profile: Profile{
   797  					Password:     "b",
   798  					Organization: "c",
   799  				},
   800  			},
   801  			expQuery:  fmt.Sprintf(validQueryF, "c", "b", ""),
   802  			errAssert: EqualError("error calling Edge API: Field \"login\" argument \"username\" of type \"String!\" is required but not provided."),
   803  		},
   804  		"Missing Password Field": {
   805  			config: Config{
   806  				Profile: Profile{
   807  					Username:     "a",
   808  					Organization: "c",
   809  				},
   810  			},
   811  			expQuery:  fmt.Sprintf(validQueryF, "c", "", "a"),
   812  			errAssert: EqualError("error calling Edge API: Field \"login\" argument \"password\" of type \"String!\" is required but not provided."),
   813  		},
   814  		"Missing Organization Field": {
   815  			config: Config{
   816  				Profile: Profile{
   817  					Username: "a",
   818  					Password: "b",
   819  				},
   820  			},
   821  			expQuery:  fmt.Sprintf(validQueryF, "", "b", "a"),
   822  			errAssert: EqualError("error calling Edge API: Field \"login\" argument \"organization\" of type \"String!\" is required but not provided."),
   823  		},
   824  		"Missing All Fields": {
   825  			config:    Config{},
   826  			expQuery:  fmt.Sprintf(validQueryF, "", "", ""),
   827  			errAssert: EqualError("error calling Edge API: Field \"login\" argument \"username\" of type \"String!\" is required but not provided."),
   828  		},
   829  	}
   830  
   831  	for name, tc := range cases {
   832  		t.Run(name, func(t *testing.T) {
   833  			server := edgeAPIMockServer(t, tc.expQuery, invalidToken)
   834  			defer server.Close()
   835  
   836  			tc.config.Profile.API = server.URL + apiV2
   837  			es, err := New(context.Background(), tc.config)
   838  			assert.NoError(t, err)
   839  
   840  			err = es.RetrieveIdentity(context.Background())
   841  			tc.errAssert(t, err)
   842  			assert.Nil(t, es.idToken)
   843  		})
   844  	}
   845  }
   846  
   847  func checkVersionServer(t *testing.T, expectedVer string) *httptest.Server {
   848  	mux := http.NewServeMux()
   849  	mux.HandleFunc("/", func(_ http.ResponseWriter, r *http.Request) {
   850  		ver := r.Header.Get(eaconst.APIVersionKey)
   851  		assert.Equal(t, expectedVer, ver)
   852  	})
   853  	server := httptest.NewServer(mux)
   854  	return server
   855  }
   856  
   857  func TestVersionHeader(t *testing.T) {
   858  	ver := eaconst.APIVersion
   859  
   860  	es, err := New(context.Background(), Config{})
   861  	assert.NoError(t, err)
   862  
   863  	server := checkVersionServer(t, ver)
   864  	defer server.Close()
   865  
   866  	req, err := http.NewRequest(http.MethodGet, server.URL, nil)
   867  	assert.NoError(t, err)
   868  	_, err = es.client.Do(req)
   869  	assert.NoError(t, err)
   870  }
   871  

View as plain text