package server import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/http/httptest" "strconv" "strings" "testing" "edge-infra.dev/pkg/lib/fog" "edge-infra.dev/pkg/sds/emergencyaccess/apierror" apierrorhandler "edge-infra.dev/pkg/sds/emergencyaccess/apierror/handler" "edge-infra.dev/pkg/sds/emergencyaccess/authservice" "edge-infra.dev/pkg/sds/emergencyaccess/eaconst" "edge-infra.dev/pkg/sds/emergencyaccess/msgdata" "edge-infra.dev/pkg/sds/emergencyaccess/types" "github.com/gin-gonic/gin" "github.com/go-logr/logr" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // testing helper type type helper interface { Helper() } type StringAssertionFunc func(t assert.TestingT, actual string, msgAndArgs ...interface{}) bool func JSONEq(expected string) StringAssertionFunc { return func(t assert.TestingT, actual string, msgAndArgs ...interface{}) bool { return assert.JSONEq(t, expected, actual, msgAndArgs...) } } func StringEqual(expected string) StringAssertionFunc { return func(t assert.TestingT, actual string, msgAndArgs ...interface{}) bool { return assert.Equal(t, expected, actual, msgAndArgs...) } } func JSONEmpty() StringAssertionFunc { return func(t assert.TestingT, actual string, msgAndArgs ...interface{}) bool { return assert.Empty(t, actual, msgAndArgs...) } } // assert.ErrorAssertionFunc that asserts the error is an api error with the given // code, and contains the given message in the error string func APIError(code apierror.ErrorCode, message string) assert.ErrorAssertionFunc { return func(tt assert.TestingT, err error, i ...interface{}) bool { if help, ok := tt.(helper); ok { help.Helper() } if !assert.ErrorContains(tt, err, message, i...) { return false } if !assert.Implements(tt, (*apierror.APIError)(nil), err, i...) { return false } e := err.(apierror.APIError) return assert.Equal(tt, code, e.Code(), i...) } } // helper function which sets well known auth headers to any request func setAuthHeaders(req *http.Request) { req.Header.Set(eaconst.HeaderAuthKeyUsername, "username") req.Header.Set(eaconst.HeaderAuthKeyEmail, "email") req.Header.Set(eaconst.HeaderAuthKeyRoles, "role") req.Header.Set(eaconst.HeaderAuthKeyBanners, "banner") } // helper function to generate auth request. func newAuthRequest(method string, url string, body io.Reader) (*http.Request, error) { req, err := http.NewRequest(method, url, body) if err != nil { return nil, err } setAuthHeaders(req) return req, nil } type mockDataset struct { authservice.Dataset } func userServiceServer() *httptest.Server { mux := http.NewServeMux() mux.HandleFunc("/eaRoles", func(w http.ResponseWriter, r *http.Request) { values := r.URL.Query() role := values.Get("role") // role may be an empty string if no role was present var roles []string if role != "" { roles = []string{role} } b, err := json.Marshal(roles) if err != nil { return } _, err = w.Write(b) if err != nil { return } }) server := httptest.NewServer(mux) return server } func TestAuthorizeCommand(t *testing.T) { tests := map[string]struct { command string expStatus int expValid bool }{ "Pass StatusOK": { `ls`, http.StatusOK, true, }, "Fail StatusOK": { `rm`, http.StatusOK, false, }, "Pass StatusOK env var": { `A=b ls`, http.StatusOK, true, }, "Fail StatusOK env var": { `A=b rm`, http.StatusOK, false, }, "Pass StatusOK escaped quotation": { `echo \"`, http.StatusOK, true, }, } for name, tc := range tests { t.Run(name, func(t *testing.T) { r := httptest.NewRecorder() // Create Gin context in test mode gin.SetMode(gin.TestMode) _, ginEngine := gin.CreateTestContext(r) // create mockservers server := mockRulesEngineServer(tc.expStatus, tc.expValid) userServer := userServiceServer() defer server.Close() defer userServer.Close() // set up authservice ds := mockDataset{} as, err := authservice.New( authservice.Config{RulesEngineHost: server.URL[7:], UserServiceHost: userServer.URL[7:]}, ds, nil, ) assert.NoError(t, err) log := fog.New() _ = New(ginEngine, log, as) // Send test query payload, _ := json.Marshal(map[string]interface{}{ "Command": tc.command, "Target": types.Target{ Bannerid: "a-banner-id", }, }) req, err := newAuthRequest(http.MethodPost, "/authorizeCommand", bytes.NewBuffer(payload)) req.Header.Add(eaconst.HeaderAuthKeyBanners, "a-banner-id") assert.NoError(t, err) ginEngine.ServeHTTP(r, req) //retrieve response assert.Equal(t, tc.expStatus, r.Result().StatusCode) var respData authservice.Validation err = unmarshalBody(r.Body, &respData) assert.NoError(t, err) assert.Equal(t, tc.expValid, respData.Valid) }) } } func TestAuthorizeCommandForbidden(t *testing.T) { t.Parallel() tests := map[string]struct { headerBanners []string payloadBanner string }{ "No Banners in header": { headerBanners: []string{}, payloadBanner: "a-banner-id", }, "Wrong banner in payload": { headerBanners: []string{"a-banner-id"}, payloadBanner: "another-banner-id", }, } for name, tc := range tests { tc := tc t.Run(name, func(t *testing.T) { t.Parallel() r := httptest.NewRecorder() // Create Gin context in test mode gin.SetMode(gin.TestMode) _, ginEngine := gin.CreateTestContext(r) // create mockservers server := mockRulesEngineServer(200, true) userServer := userServiceServer() defer server.Close() defer userServer.Close() // set up authservice ds := mockDataset{} as, err := authservice.New( authservice.Config{RulesEngineHost: server.URL[7:], UserServiceHost: userServer.URL[7:]}, ds, nil, ) assert.NoError(t, err) log := fog.New() _ = New(ginEngine, log, as) // Send test query payload, _ := json.Marshal(map[string]interface{}{ "Command": "ls", "Target": types.Target{ Bannerid: tc.payloadBanner, }, }) req, err := http.NewRequest(http.MethodPost, "/authorizeCommand", bytes.NewBuffer(payload)) for _, banner := range tc.headerBanners { req.Header.Add(eaconst.HeaderAuthKeyBanners, banner) } req.Header.Add(eaconst.HeaderAuthKeyEmail, "user@ncr.com") req.Header.Add(eaconst.HeaderAuthKeyUsername, "username") assert.NoError(t, err) ginEngine.ServeHTTP(r, req) //retrieve response assert.Equal(t, http.StatusForbidden, r.Result().StatusCode) }) } } func TestAuthCommandAudit(t *testing.T) { r := httptest.NewRecorder() // Create Gin context in test mode gin.SetMode(gin.TestMode) _, ginEngine := gin.CreateTestContext(r) // create mockservers. needed as we want a successful authorization to be logged. rServer, uServer := mockRulesEngineServer(200, true), userServiceServer() defer rServer.Close() defer uServer.Close() // set up authservice with logging to memory ds := mockDataset{} as, err := authservice.New( authservice.Config{RulesEngineHost: rServer.URL[7:], UserServiceHost: uServer.URL[7:]}, ds, nil, ) assert.NoError(t, err) b := bytes.Buffer{} log := fog.New(fog.To(&b)) _ = New(ginEngine, log, as) // Send test query payload, _ := json.Marshal(map[string]interface{}{ "Command": "someCommand", "EARoles": []string{"test"}, "Target": types.Target{ Projectid: "a-project-id", Bannerid: "a-banner-id", Storeid: "a-store-id", Terminalid: "a-terminal-id", }, }) req, err := newAuthRequest(http.MethodPost, "/authorizeCommand", bytes.NewBuffer(payload)) assert.NoError(t, err) req.Header.Add("X-Correlation-ID", "a-command-id") ginEngine.ServeHTTP(r, req) // Test. validateAuditLog(t, &b, "Authorize Command Called", map[string]string{ "command": "someCommand", "requestID": "a-command-id", "userID": "username", "targetProjectID": "a-project-id", "targetBannerUUID": "a-banner-id", "targetStoreUUID": "a-store-id", "targetTerminalUUID": "a-terminal-id", }) } func validateAuditLog(t *testing.T, b *bytes.Buffer, logmsg string, keyVals map[string]string) { // split the log on newline char to test whether the condition is satisfied in a single entry rather than across all logs lst := strings.Split(b.String(), "\n") var ok bool for _, str := range lst { // select the log with the correct log message if ok = strings.Contains(str, logmsg); ok { validateKeyValPairs(t, str, keyVals) break } } assert.True(t, ok, "log with message %q not found", logmsg) } func validateKeyValPairs(t *testing.T, logString string, keyVals map[string]string) { for name, val := range keyVals { // Bools aren't printed with quotes if _, err := strconv.ParseBool(val); err == nil || name == "request" { assert.Contains(t, logString, fmt.Sprintf("%q:%s", name, val)) } else { assert.Contains(t, logString, fmt.Sprintf("%q:%q", name, val)) } } } // only compares the command for each payload as we are not testing anything to do with target/identity func darkmodeServer(t *testing.T, expPayload authservice.RulesEnginePayload) *httptest.Server { mux := http.NewServeMux() mux.HandleFunc("/validatecommand", func(w http.ResponseWriter, r *http.Request) { // read the incoming data data, err := io.ReadAll(r.Body) assert.NoError(t, err) var in authservice.RulesEnginePayload assert.NoError(t, json.Unmarshal(data, &in)) // test assert.Equal(t, expPayload.Command, in.Command) //write header after tests have completed w.WriteHeader(200) // write response res := authservice.Response{Valid: true} b, err := json.Marshal(res) assert.NoError(t, err) _, err = w.Write(b) assert.NoError(t, err) }) return httptest.NewServer(mux) } // checks the payload being sent from authservice to the reng matches expected payload func TestAuthorizeCommandDarkMode(t *testing.T) { tests := map[string]struct { payload authservice.CommandAuthPayload expPayload authservice.RulesEnginePayload }{ "Darkmode true": { payload: authservice.CommandAuthPayload{ Command: "ls", Target: authservice.Target{BannerID: "a-banner-id"}, AuthDetails: authservice.AuthDetails{DarkMode: true}, }, expPayload: authservice.RulesEnginePayload{ Command: authservice.RulesEngineCommand{ Name: "dark", Type: "command", }, }, }, "Darkmode false": { payload: authservice.CommandAuthPayload{ Command: "ls", Target: authservice.Target{BannerID: "a-banner-id"}, AuthDetails: authservice.AuthDetails{DarkMode: false}, }, expPayload: authservice.RulesEnginePayload{ Command: authservice.RulesEngineCommand{ Name: "ls", Type: "command", }, }, }, } for name, tc := range tests { tc := tc t.Run(name, func(t *testing.T) { t.Parallel() r := httptest.NewRecorder() // Create Gin context in test mode gin.SetMode(gin.TestMode) _, ginEngine := gin.CreateTestContext(r) // create mockservers server := darkmodeServer(t, tc.expPayload) userServer := userServiceServer() defer server.Close() defer userServer.Close() // set up authservice ds := mockDataset{} as, err := authservice.New( authservice.Config{ RulesEngineHost: server.URL[7:], UserServiceHost: userServer.URL[7:], }, ds, nil, ) assert.NoError(t, err) log := fog.New() _ = New(ginEngine, log, as) // Send test query payload, err := json.Marshal(tc.payload) assert.NoError(t, err) req, err := newAuthRequest(http.MethodPost, "/authorizeCommand", bytes.NewBuffer(payload)) assert.NoError(t, err) req.Header.Add(eaconst.HeaderAuthKeyBanners, "a-banner-id") ginEngine.ServeHTTP(r, req) // wait for result from server assert.Equal(t, 200, r.Result().StatusCode) }) } } func TestAuthorizeCommandBadPayload(t *testing.T) { tests := map[string]struct { payload string expStatus int expErr apierror.ErrorCode }{ "Fail Status 400 invalid json": { `{"command":"rm","earoles":["test"]`, http.StatusBadRequest, apierror.ErrPayloadStructure, }, "Fail Status 400 no command with env var": { `{"command":"A=b","target":{"bannerid":"a-banner-id"}}`, http.StatusBadRequest, apierror.ErrInvalidCommand, }, "Fail Status 400 no command with env var darkmode": { `{"command":"A=b","target":{"bannerid":"a-banner-id"},"authDetails":{"darkmode":true}}`, http.StatusBadRequest, apierror.ErrInvalidCommand, }, "Fail Status 400 no command": { `{"target":{"bannerid":"a-banner-id"}}`, http.StatusBadRequest, apierror.ErrPayloadProperties, }, "Fail Status 400 no target": { `{"command":"rm"}`, http.StatusBadRequest, apierror.ErrPayloadProperties, }, } for name, tc := range tests { t.Run(name, func(t *testing.T) { r := httptest.NewRecorder() // Create Gin context in test mode gin.SetMode(gin.TestMode) _, ginEngine := gin.CreateTestContext(r) // no need to set up rules engine server since it's never reached // set up authservice ds := mockDataset{} as, err := authservice.New( authservice.Config{}, ds, nil, ) assert.NoError(t, err) _ = New(ginEngine, fog.New(), as) // Send test query req, err := newAuthRequest(http.MethodPost, "/authorizeCommand", strings.NewReader(tc.payload)) assert.NoError(t, err) ginEngine.ServeHTTP(r, req) //retrieve response assert.Equal(t, tc.expStatus, r.Result().StatusCode) var e apierrorhandler.ErrorResponse err = unmarshalBody(r.Body, &e) assert.NoError(t, err) assert.Equal(t, tc.expErr, e.ErrorCode) }) } } func TestAuthorizeCommandBadCommand(t *testing.T) { tests := map[string]struct { command string expStatus int expErr apierror.ErrorCode }{ "Fail Status 400 quotation mark": { `echo "`, http.StatusBadRequest, apierror.ErrInvalidCommand, }, "Fail Status 400 double escaped quotation mark": { `echo \\"`, http.StatusBadRequest, apierror.ErrInvalidCommand, }, } for name, tc := range tests { t.Run(name, func(t *testing.T) { r := httptest.NewRecorder() // Create Gin context in test mode gin.SetMode(gin.TestMode) _, ginEngine := gin.CreateTestContext(r) // set up authservice ds := mockDataset{} as, err := authservice.New(authservice.Config{}, ds, nil) assert.NoError(t, err) _ = New(ginEngine, fog.New(), as) // Send test query payload, _ := json.Marshal(map[string]interface{}{ "Command": tc.command, "EARoles": []string{"test"}, "Target": types.Target{ Bannerid: "a-banner-id", }, }) req, err := newAuthRequest(http.MethodPost, "/authorizeCommand", bytes.NewBuffer(payload)) assert.NoError(t, err) ginEngine.ServeHTTP(r, req) //retrieve response assert.Equal(t, tc.expStatus, r.Result().StatusCode) var e apierrorhandler.ErrorResponse err = unmarshalBody(r.Body, &e) assert.NoError(t, err) assert.Equal(t, tc.expErr, e.ErrorCode) }) } } func unmarshalBody(body *bytes.Buffer, v any) error { data, err := io.ReadAll(body) if err != nil { return err } return json.Unmarshal(data, v) } type mockAuthService struct { authorizeCommand func(ctx context.Context, payload authservice.CommandAuthPayload) (authservice.Validation, error) authorizeRequest func(ctx context.Context, payload authservice.AuthorizeRequestPayload) (msgdata.Request, error) authorizeTarget func(ctx context.Context, target authservice.Target) error authorizeUser func(ctx context.Context) error resolveTarget func(ctx context.Context, payload authservice.ResolveTargetPayload) (authservice.Target, error) } func (mas mockAuthService) AuthorizeCommand(ctx context.Context, payload authservice.CommandAuthPayload) (authservice.Validation, error) { return mas.authorizeCommand(ctx, payload) } func (mas mockAuthService) AuthorizeRequest(ctx context.Context, payload authservice.AuthorizeRequestPayload) (msgdata.Request, error) { return mas.authorizeRequest(ctx, payload) } func (mas mockAuthService) AuthorizeTarget(ctx context.Context, target authservice.Target) error { return mas.authorizeTarget(ctx, target) } func (mas mockAuthService) AuthorizeUser(ctx context.Context) error { return mas.authorizeUser(ctx) } func (mas mockAuthService) ResolveTarget(ctx context.Context, payload authservice.ResolveTargetPayload) (authservice.Target, error) { return mas.resolveTarget(ctx, payload) } func TestAuthorizeRequestSuccess(t *testing.T) { t.Parallel() tests := map[string]struct { payload []byte expectedData string expectedAttributes map[string]string }{ "1.0 Command": { payload: []byte(`{ "request": { "data": { "command": "echo hello there" }, "attributes": { "version": "1.0", "type": "command" } }, "target": { "projectID": "project", "bannerID": "banner", "storeID": "store", "terminalID": "terminal" } }`), expectedData: `{ "command": "echo hello there" }`, expectedAttributes: map[string]string{ eaconst.VersionKey: string(eaconst.MessageVersion1_0), eaconst.RequestTypeKey: string(eaconst.Command), }, }, "2.0 Command": { payload: []byte(`{ "request": { "data": { "command": "echo", "args": ["hello", "there"] }, "attributes": { "version": "2.0", "type": "command" } }, "target": { "projectID": "project", "bannerID": "banner", "storeID": "store", "terminalID": "terminal" } }`), expectedData: `{ "command": "echo", "args": ["hello", "there"] }`, expectedAttributes: map[string]string{ eaconst.VersionKey: string(eaconst.MessageVersion2_0), eaconst.RequestTypeKey: string(eaconst.Command), }, }, } for name, tc := range tests { tc := tc t.Run(name, func(t *testing.T) { t.Parallel() r := httptest.NewRecorder() // Create Gin context in test mode gin.SetMode(gin.TestMode) _, ginEngine := gin.CreateTestContext(r) ruleServer := mockRulesEngineServer(http.StatusOK, true) userServer := userServiceServer() defer ruleServer.Close() defer userServer.Close() // set up authservice ds := mockDataset{} as, err := authservice.New( authservice.Config{RulesEngineHost: ruleServer.URL[7:], UserServiceHost: userServer.URL[7:]}, ds, nil, ) require.NoError(t, err) log := fog.New() _ = New(ginEngine, log, as) req, err := newAuthRequest(http.MethodPost, "/authorizeRequest", bytes.NewBuffer(tc.payload)) require.NoError(t, err) ginEngine.ServeHTTP(r, req) //retrieve response assert.Equal(t, http.StatusOK, r.Result().StatusCode) var resp struct { Request authservice.Request } err = unmarshalBody(r.Body, &resp) assert.NoError(t, err) data, err := json.Marshal(resp.Request.Data) assert.NoError(t, err) assert.JSONEq(t, tc.expectedData, string(data)) assert.Equal(t, tc.expectedAttributes, resp.Request.Attributes) }) } } func TestAuthorizeRequestFail(t *testing.T) { t.Parallel() tests := map[string]struct { payload []byte authorizeRequest func(context.Context, authservice.AuthorizeRequestPayload) (msgdata.Request, error) expStatus int expError apierror.ErrorCode }{ "Invalid Payload Structure": { payload: []byte(`{ "request": { "data": { "command": "echo he}`), expStatus: http.StatusBadRequest, expError: apierror.ErrPayloadStructure, }, "Invalid Payload Details": { payload: []byte(`{ "request": { "data": { "command": "" }, "attributes": { "version": "1.0", "type": "command" } }, "target": { "projectID": "project", "bannerID": "", "storeID": "store", "terminalID": "terminal" } }`), expStatus: http.StatusBadRequest, expError: apierror.ErrPayloadProperties, }, "Send Failure": { payload: []byte(`{ "request": { "data": { "command": "echo hello there" }, "attributes": { "version": "1.0", "type": "command" } }, "target": { "projectID": "project", "bannerID": "banner", "storeID": "store", "terminalID": "terminal" } }`), authorizeRequest: func(_ context.Context, _ authservice.AuthorizeRequestPayload) (msgdata.Request, error) { return nil, errors.New("error") }, expStatus: http.StatusInternalServerError, expError: apierror.ErrSendFailure, }, "Unauthorized Command": { payload: []byte(`{ "request": { "data": { "command": "echo hello there" }, "attributes": { "version": "1.0", "type": "command" } }, "target": { "projectID": "project", "bannerID": "banner", "storeID": "store", "terminalID": "terminal" } }`), authorizeRequest: func(_ context.Context, _ authservice.AuthorizeRequestPayload) (msgdata.Request, error) { return nil, apierror.E(apierror.ErrUnauthorizedCommand, errors.New("error")) }, expStatus: http.StatusForbidden, expError: apierror.ErrUnauthorizedCommand, }, } for name, tc := range tests { tc := tc t.Run(name, func(t *testing.T) { t.Parallel() r := httptest.NewRecorder() // Create Gin context in test mode gin.SetMode(gin.TestMode) _, ginEngine := gin.CreateTestContext(r) // set up authservice as := mockAuthService{ authorizeRequest: tc.authorizeRequest, } log := fog.New() _ = New(ginEngine, log, as) req, err := newAuthRequest(http.MethodPost, "/authorizeRequest", bytes.NewBuffer(tc.payload)) require.NoError(t, err) ginEngine.ServeHTTP(r, req) //retrieve response assert.Equal(t, tc.expStatus, r.Result().StatusCode) var e apierrorhandler.ErrorResponse err = unmarshalBody(r.Body, &e) assert.NoError(t, err) assert.Equal(t, tc.expError, e.ErrorCode) }) } } func TestAuthorizeRequestAudit(t *testing.T) { t.Parallel() tests := map[string]struct { authorizeRequest func(context.Context, authservice.AuthorizeRequestPayload) (msgdata.Request, error) expAuth bool }{ "Success": { authorizeRequest: func(_ context.Context, _ authservice.AuthorizeRequestPayload) (msgdata.Request, error) { return nil, nil }, expAuth: true, }, "Unauthorized": { authorizeRequest: func(_ context.Context, _ authservice.AuthorizeRequestPayload) (msgdata.Request, error) { return nil, errors.New("error") }, expAuth: false, }, } for name, tc := range tests { tc := tc t.Run(name, func(t *testing.T) { t.Parallel() // Create Gin context in test mode r := httptest.NewRecorder() gin.SetMode(gin.TestMode) _, ginEngine := gin.CreateTestContext(r) // set up authservice with logging to memory b := bytes.Buffer{} log := fog.New(fog.To(&b)) as := mockAuthService{authorizeRequest: tc.authorizeRequest} _ = New(ginEngine, log, as) // Create request map for log comparison later requestMap := map[string]map[string]string{ "data": { "command": "echo hello there", }, "attributes": { "type": "command", "version": "1.0", }, } requestBytes, err := json.Marshal(requestMap) assert.NoError(t, err) payload, err := json.Marshal(map[string]interface{}{ "request": requestMap, "target": authservice.Target{ ProjectID: "project", BannerID: "banner", StoreID: "store", TerminalID: "terminal", }, }) require.NoError(t, err) req, err := newAuthRequest(http.MethodPost, "/authorizeRequest", bytes.NewBuffer(payload)) require.NoError(t, err) req.Header.Add("X-Correlation-ID", "a-command-id") ginEngine.ServeHTTP(r, req) // Retrieve audit log line and convert to map auditLog, err := getAuditLogString(&b, "Authorize Request Called") assert.NoError(t, err) var auditMap map[string]interface{} err = json.Unmarshal([]byte(auditLog), &auditMap) assert.NoError(t, err) // Convert audit log "request" value to JSON and compare with actual request auditReqMap, ok := auditMap["request"].(map[string]interface{}) assert.True(t, ok) auditReqBytes, err := json.Marshal(auditReqMap) assert.NoError(t, err) assert.JSONEq(t, string(requestBytes), string(auditReqBytes)) // Iterate through the rest of the expected audit values expectedLogKeyVals := map[string]interface{}{ "requestID": "a-command-id", "userID": "username", "targetProjectID": "project", "targetBannerUUID": "banner", "targetStoreUUID": "store", "targetTerminalUUID": "terminal", "commandAuthorized": tc.expAuth, } for expKey, expVal := range expectedLogKeyVals { auditVal, ok := auditMap[expKey] assert.True(t, ok) assert.Equal(t, expVal, auditVal) } }) } } func getAuditLogString(b *bytes.Buffer, logmsg string) (string, error) { // split the log on newline char to test whether the condition is satisfied in a single entry rather than across all logs lst := strings.Split(b.String(), "\n") var ok bool for _, str := range lst { // select the log with the correct log message if ok = strings.Contains(str, logmsg); ok { return str, nil } } return "", errors.New("could not find matching log message") } func mockRulesEngineServer(statusCode int, valid bool) *httptest.Server { mux := http.NewServeMux() mux.HandleFunc("/validatecommand", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(statusCode) res := authservice.Response{Valid: valid} b, err := json.Marshal(res) if err != nil { return } _, err = w.Write(b) if err != nil { return } }) return httptest.NewServer(mux) } type mockDatasetTestResolveTarget struct { authservice.Dataset projectID string bannerID string storeID string terminalID string } const errVal = "err" func (ds mockDatasetTestResolveTarget) GetProjectAndBannerID(_ context.Context, banner string) (projectID string, bannerID string, err error) { if banner == errVal { err = fmt.Errorf("error GetProjectIDAndBannerID") } return ds.projectID, ds.bannerID, err } func (ds mockDatasetTestResolveTarget) GetStoreID(_ context.Context, store, _ string) (storeID string, err error) { if store == errVal { err = fmt.Errorf("error GetStoreID") } return ds.storeID, err } func (ds mockDatasetTestResolveTarget) GetTerminalID(_ context.Context, terminal, _ string) (terminalID string, err error) { if terminal == errVal { err = fmt.Errorf("error GetTerminalID") } return ds.terminalID, err } func TestResolveTarget(t *testing.T) { t.Parallel() tests := map[string]struct { data []byte ds mockDatasetTestResolveTarget expCode int expOutput StringAssertionFunc }{ "Valid": { data: []byte(`{ "target": { "bannerid": "b", "storeid": "s", "terminalid": "t" } }`), ds: mockDatasetTestResolveTarget{ projectID: "projectID", bannerID: "bannerID", storeID: "storeID", terminalID: "terminalID", }, expCode: http.StatusOK, expOutput: JSONEq(`{ "target": { "projectid": "projectID", "bannerid": "bannerID", "storeid": "storeID", "terminalid": "terminalID" } }`), }, "Bad Payload": { data: []byte(`{"targ}`), expCode: http.StatusBadRequest, expOutput: JSONEq(`{"errorCode":60201, "errorMessage":"Request Error - Invalid payload structure"}`), }, "Invalid Payload": { data: []byte(`{ "target": {} }`), expCode: http.StatusBadRequest, expOutput: JSONEq(`{"errorCode":60202,"errorMessage":"Request Error - Invalid payload properties","details":["Payload missing banner ID","Payload missing store ID","Payload missing terminal ID"]}`), }, "Failed To Authorize": { data: []byte(`{ "target": { "bannerid": "err", "storeid": "err", "terminalid": "err" } }`), expCode: http.StatusInternalServerError, expOutput: JSONEq(`{"errorCode":60101, "errorMessage":"User Authorization Failure - Failed to authorize user"}`), }, } for name, tc := range tests { tc := tc t.Run(name, func(t *testing.T) { t.Parallel() r := httptest.NewRecorder() gin.SetMode(gin.TestMode) _, ginEngine := gin.CreateTestContext(r) as, err := authservice.New( authservice.Config{}, tc.ds, nil, ) assert.NoError(t, err) _ = New(ginEngine, logr.Discard(), as) req, err := newAuthRequest(http.MethodPost, "/resolveTarget", bytes.NewBuffer(tc.data)) assert.NoError(t, err) ginEngine.ServeHTTP(r, req) assert.Equal(t, tc.expCode, r.Result().StatusCode) data, err := io.ReadAll(r.Body) assert.NoError(t, err) tc.expOutput(t, string(data)) }) } } const ( uuid1 = "78587bb1-6ca2-4d2d-a223-1ee642514b97" uuid2 = "35cc70eb-689d-49d4-8bd8-fa1cb8b0928f" uuid3 = "79bf815d-8e64-4b01-b12e-1f173a322766" uuid4 = "113f6c32-5501-44ba-9cd5-76530be5aa67" payloadString = ` { "target": { "projectid":"%s", "bannerid": "%s", "storeid": "%s", "terminalid": "%s" } }` ) func TestAuthorizeTarget(t *testing.T) { // Testing whether the endpoint returns correct error codes. There are no strict requirements on UUIDs for this endpoint, // but the UUID/string parse structure has been left incase this changes. tests := map[string]struct { data []byte expCode int bannerID string }{ "Valid": { data: []byte(fmt.Sprintf(payloadString, uuid1, uuid2, uuid3, uuid4)), expCode: 200, bannerID: uuid2, }, "Wrong bannerID (forbidden)": { data: []byte(fmt.Sprintf(payloadString, uuid1, uuid2, uuid3, uuid4)), expCode: 403, bannerID: uuid1, }, "Bad payload": { data: []byte("{"), expCode: 400, bannerID: uuid1, }, } for name, tc := range tests { tc := tc t.Run(name, func(t *testing.T) { t.Parallel() r := httptest.NewRecorder() gin.SetMode(gin.TestMode) _, ginEngine := gin.CreateTestContext(r) // userservice server uServer := userServiceServer() defer uServer.Close() as, err := authservice.New( authservice.Config{UserServiceHost: uServer.URL[7:]}, mockDataset{}, nil, ) assert.NoError(t, err) _ = New(ginEngine, logr.Discard(), as) req, err := newAuthRequest(http.MethodPost, "/authorizeTarget", bytes.NewBuffer(tc.data)) assert.NoError(t, err) req.Header.Add(eaconst.HeaderAuthKeyBanners, tc.bannerID) ginEngine.ServeHTTP(r, req) assert.Equal(t, tc.expCode, r.Result().StatusCode) }) } } func TestAuthTargetAudit(t *testing.T) { // Setup r := httptest.NewRecorder() gin.SetMode(gin.TestMode) _, ginEngine := gin.CreateTestContext(r) // userservice server uServer := userServiceServer() defer uServer.Close() // new dataset ds := mockDataset{} // new authservice. rules engine not needed for authTarget. as, err := authservice.New( authservice.Config{UserServiceHost: uServer.URL[7:]}, ds, nil, ) assert.NoError(t, err) // logging to memory so it can be read from test b := bytes.Buffer{} log := fog.New(fog.To(&b)) _ = New(ginEngine, log, as) // setup request req, err := newAuthRequest(http.MethodPost, "/authorizeTarget", bytes.NewBuffer([]byte(fmt.Sprintf(payloadString, uuid1, uuid2, uuid3, uuid4)))) assert.NoError(t, err) req.Header.Add(eaconst.HeaderAuthKeyBanners, uuid2) // serve request ginEngine.ServeHTTP(r, req) assert.Equal(t, 200, r.Result().StatusCode) // Test validateAuditLog(t, &b, "Authorize Target Called", map[string]string{ "userID": "username", "targetProjectID": uuid1, "targetBannerUUID": uuid2, "targetStoreUUID": uuid3, "targetTerminalUUID": uuid4, }) } func TestAuthorizeUser(t *testing.T) { t.Parallel() tests := map[string]struct { ctx context.Context setAuthHeaders func(req *http.Request) expCode int }{ "Valid": { setAuthHeaders: setAuthHeaders, expCode: http.StatusOK, }, "No User": { ctx: context.Background(), setAuthHeaders: func(_ *http.Request) {}, expCode: http.StatusForbidden, }, "Invalid Roles": { setAuthHeaders: func(req *http.Request) { req.Header.Set(eaconst.HeaderAuthKeyUsername, "username") req.Header.Set(eaconst.HeaderAuthKeyEmail, "email") req.Header.Set(eaconst.HeaderAuthKeyBanners, "banner") }, expCode: http.StatusForbidden, }, } for name, tc := range tests { tc := tc t.Run(name, func(t *testing.T) { t.Parallel() r := httptest.NewRecorder() gin.SetMode(gin.TestMode) _, ginEngine := gin.CreateTestContext(r) // Setup userServer := userServiceServer() defer userServer.Close() ds := mockDataset{} as, err := authservice.New( authservice.Config{UserServiceHost: userServer.URL[7:]}, ds, nil, ) assert.NoError(t, err) _ = New(ginEngine, fog.New(), as) // Test req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, "/authorizeUser", nil) assert.NoError(t, err) tc.setAuthHeaders(req) ginEngine.ServeHTTP(r, req) assert.Equal(t, tc.expCode, r.Result().StatusCode) }) } } func TestHealth(t *testing.T) { t.Parallel() tests := map[string]struct { checks []func() error expCode int expData string }{ "No checks": { checks: nil, expCode: http.StatusOK, expData: "ok", }, "Passing check": { checks: []func() error{func() error { return nil }}, expCode: http.StatusOK, expData: "ok", }, "Failing check": { checks: []func() error{func() error { return fmt.Errorf("this is bad") }}, expCode: http.StatusServiceUnavailable, expData: "failed health check: this is bad", }, "Two checks": { checks: []func() error{func() error { return nil }, func() error { return fmt.Errorf("this is bad") }}, expCode: http.StatusServiceUnavailable, expData: "failed health check: this is bad", }, } for name, tc := range tests { tc := tc t.Run(name, func(t *testing.T) { t.Parallel() r := httptest.NewRecorder() gin.SetMode(gin.TestMode) _, ginEngine := gin.CreateTestContext(r) as, err := authservice.New( authservice.Config{}, nil, nil, ) assert.NoError(t, err) _ = New(ginEngine, logr.Discard(), as, tc.checks...) req, err := newAuthRequest(http.MethodGet, "/health", nil) assert.NoError(t, err) ginEngine.ServeHTTP(r, req) assert.Equal(t, tc.expCode, r.Result().StatusCode) data, err := io.ReadAll(r.Body) assert.NoError(t, err) assert.Equal(t, tc.expData, string(data)) }) } }