package server import ( "context" "fmt" "io" "net/http" "net/http/httptest" "strings" "testing" "github.com/gin-gonic/gin" "github.com/go-logr/logr" "github.com/stretchr/testify/assert" rulesengine "edge-infra.dev/pkg/sds/emergencyaccess/rules" ) type postNamesMock struct { RulesEngine dataRet rulesengine.AddNameResult errRet error callCount int names []string } func (pnm *postNamesMock) AddCommands(_ context.Context, commands []rulesengine.PostCommandPayload) (rulesengine.AddNameResult, error) { pnm.callCount = pnm.callCount + 1 for _, command := range commands { pnm.names = append(pnm.names, command.Name) } return pnm.dataRet, pnm.errRet } func (pnm *postNamesMock) AddPrivileges(_ context.Context, privs []rulesengine.PostPrivilegePayload) (rulesengine.AddNameResult, error) { pnm.callCount = pnm.callCount + 1 for _, priv := range privs { pnm.names = append(pnm.names, priv.Name) } return pnm.dataRet, pnm.errRet } func TestPostNames(t *testing.T) { t.Parallel() tests := map[string]struct { url string reqBody string mockDataRet rulesengine.AddNameResult mockErrRet error expMockCalledCount int expNames []string expCode int jsonAssert StringAssertionFunc }{ "Post Commands Ok": { url: "/admin/commands", reqBody: `[ {"name": "ls"}, {"name": "cat"} ]`, expMockCalledCount: 1, expNames: []string{"ls", "cat"}, expCode: http.StatusOK, jsonAssert: JSONEmpty(), }, "Post Commands Invalid JSON": { url: "/admin/commands", reqBody: `[{"nam`, expMockCalledCount: 0, expCode: http.StatusBadRequest, jsonAssert: JSONEmpty(), }, "Post Commands Invalid Payload": { url: "/admin/commands", reqBody: `[ {"name": ""}, ]`, expMockCalledCount: 0, expCode: http.StatusBadRequest, jsonAssert: JSONEmpty(), }, "Post Commands Rules Engine Error": { url: "/admin/commands", reqBody: `[ {"name": "ls"}, {"name": "cat"} ]`, mockDataRet: rulesengine.AddNameResult{}, mockErrRet: fmt.Errorf("an error occurred"), expMockCalledCount: 1, expNames: []string{"ls", "cat"}, expCode: http.StatusInternalServerError, jsonAssert: JSONEmpty(), }, "Post Commands Rules Engine Conflict": { url: "/admin/commands", reqBody: `[ {"name": "ls"}, {"name": "cat"} ]`, mockDataRet: rulesengine.AddNameResult{Conflicts: []string{"ls", "cat"}}, expMockCalledCount: 1, expNames: []string{"ls", "cat"}, expCode: http.StatusConflict, jsonAssert: JSONEq(`{ "conflicts": [ "ls", "cat" ] }`), }, "Post Privilege Ok": { url: "/admin/privileges", reqBody: `[ {"name": "basic"}, {"name": "admin"} ]`, expMockCalledCount: 1, expNames: []string{"basic", "admin"}, expCode: http.StatusOK, jsonAssert: JSONEmpty(), }, "Post Privilege Invalid JSON": { url: "/admin/privileges", reqBody: `[{"nam`, expMockCalledCount: 0, expCode: http.StatusBadRequest, jsonAssert: JSONEmpty(), }, "Post Privilege Invalid Payload": { url: "/admin/privileges", reqBody: `[ {"name": ""}, ]`, expMockCalledCount: 0, expCode: http.StatusBadRequest, jsonAssert: JSONEmpty(), }, "Post Privilege Rules Engine Error": { url: "/admin/privileges", reqBody: `[ {"name": "basic"}, {"name": "admin"} ]`, mockDataRet: rulesengine.AddNameResult{}, mockErrRet: fmt.Errorf("an error occurred"), expMockCalledCount: 1, expNames: []string{"basic", "admin"}, expCode: http.StatusInternalServerError, jsonAssert: JSONEmpty(), }, "Post Privilege Rules Engine Conflict": { url: "/admin/privileges", reqBody: `[ {"name": "basic"}, {"name": "admin"} ]`, mockDataRet: rulesengine.AddNameResult{Conflicts: []string{"basic", "admin"}}, expMockCalledCount: 1, expNames: []string{"basic", "admin"}, expCode: http.StatusConflict, jsonAssert: JSONEq(`{ "conflicts": [ "basic", "admin" ] }`), }, } for name, tc := range tests { tc := tc t.Run(name, func(t *testing.T) { t.Parallel() ruleseng := postNamesMock{ dataRet: tc.mockDataRet, errRet: tc.mockErrRet, } r := httptest.NewRecorder() _, ginEngine := getTestGinContext(r) _, err := New(ginEngine, &ruleseng, newLogger()) assert.NoError(t, err) req, err := http.NewRequest(http.MethodPost, tc.url, strings.NewReader(tc.reqBody)) assert.NoError(t, err) ginEngine.ServeHTTP(r, req) assert.Equal(t, tc.expCode, r.Result().StatusCode) assert.Equal(t, tc.expMockCalledCount, ruleseng.callCount) assert.Equal(t, tc.expNames, ruleseng.names) tc.jsonAssert(t, r.Body.String()) }) } } type deleteMock struct { RulesEngine command, privilege string retDelete rulesengine.DeleteResult retErr error } func (dm *deleteMock) DeleteCommand(_ context.Context, name string) (rulesengine.DeleteResult, error) { dm.command = name return dm.retDelete, dm.retErr } func (dm *deleteMock) DeletePrivilege(_ context.Context, name string) (rulesengine.DeleteResult, error) { dm.privilege = name return dm.retDelete, dm.retErr } func (dm *deleteMock) DeleteDefaultRule(_ context.Context, commandName string, privilegeName string) (rulesengine.DeleteResult, error) { dm.command = commandName dm.privilege = privilegeName return dm.retDelete, dm.retErr } func TestDelete(t *testing.T) { t.Parallel() tests := map[string]struct { url string command string privilege string retRes rulesengine.DeleteResult retErr error expStatus int expOut StringAssertionFunc }{ "Delete Command: Success": { command: "ls", url: "/admin/commands/ls", retRes: rulesengine.DeleteResult{RowsAffected: 1}, retErr: nil, expStatus: http.StatusOK, expOut: JSONEmpty(), }, "Delete Command: No Change With Errors": { command: "ls", url: "/admin/commands/ls", retRes: rulesengine.DeleteResult{RowsAffected: 0, Errors: []rulesengine.Error{{Type: rulesengine.UnknownCommand, Command: "ls"}}}, retErr: nil, expStatus: http.StatusNotFound, expOut: JSONEq(`{"errors":[{"type":"Unknown Command","command":"ls"}]}`), }, "Delete Command: Rule With Conflicts": { command: "ls", url: "/admin/commands/ls", retRes: rulesengine.DeleteResult{RowsAffected: 0, Errors: []rulesengine.Error{{Type: rulesengine.Conflict, Command: "ls"}}}, retErr: nil, expStatus: http.StatusConflict, expOut: JSONEq(`{"errors":[{"type":"Conflict","command":"ls"}]}`), }, "Delete Command: Application Error": { command: "ls", url: "/admin/commands/ls", retRes: rulesengine.DeleteResult{RowsAffected: 1}, retErr: fmt.Errorf("an error"), expStatus: http.StatusInternalServerError, expOut: JSONEmpty(), }, "Delete Privilege: Success": { privilege: "basic", url: "/admin/privileges/basic", retRes: rulesengine.DeleteResult{RowsAffected: 1}, retErr: nil, expStatus: http.StatusOK, expOut: JSONEmpty(), }, "Delete Privilege: No Change With Errors": { privilege: "basic", url: "/admin/privileges/basic", retRes: rulesengine.DeleteResult{RowsAffected: 0, Errors: []rulesengine.Error{{Type: rulesengine.UnknownCommand, Command: "ls"}}}, retErr: nil, expStatus: http.StatusNotFound, expOut: JSONEq(`{"errors":[{"type":"Unknown Command","command":"ls"}]}`), }, "Delete Privilege: With Conflicts": { privilege: "basic", url: "/admin/privileges/basic", retRes: rulesengine.DeleteResult{RowsAffected: 0, Errors: []rulesengine.Error{{Type: rulesengine.Conflict, Command: "ls"}}}, retErr: nil, expStatus: http.StatusConflict, expOut: JSONEq(`{"errors":[{"type":"Conflict","command":"ls"}]}`), }, "Delete Privilege: Application Error": { privilege: "basic", url: "/admin/privileges/basic", retRes: rulesengine.DeleteResult{RowsAffected: 1}, retErr: fmt.Errorf("an error"), expStatus: http.StatusInternalServerError, expOut: JSONEmpty(), }, "Delete Rule: Success": { command: "ls", privilege: "basic", url: "/admin/rules/default/commands/ls/privileges/basic", retRes: rulesengine.DeleteResult{RowsAffected: 1}, retErr: nil, expStatus: http.StatusOK, expOut: JSONEmpty(), }, "Delete Rule: No Change With Errors": { command: "ls", privilege: "basic", url: "/admin/rules/default/commands/ls/privileges/basic", retRes: rulesengine.DeleteResult{RowsAffected: 0, Errors: []rulesengine.Error{{Type: rulesengine.UnknownCommand, Command: "ls"}}}, retErr: nil, expStatus: http.StatusNotFound, expOut: JSONEq(`{"errors":[{"type":"Unknown Command","command":"ls"}]}`), }, "Delete Rule: With Conflicts": { command: "ls", privilege: "basic", url: "/admin/rules/default/commands/ls/privileges/basic", retRes: rulesengine.DeleteResult{RowsAffected: 0, Errors: []rulesengine.Error{{Type: rulesengine.Conflict, Command: "ls"}}}, retErr: nil, expStatus: http.StatusConflict, expOut: JSONEq(`{"errors":[{"type":"Conflict","command":"ls"}]}`), }, "Delete Rule: Application Error": { command: "ls", privilege: "basic", url: "/admin/rules/default/commands/ls/privileges/basic", retRes: rulesengine.DeleteResult{RowsAffected: 1}, retErr: fmt.Errorf("an error"), expStatus: http.StatusInternalServerError, expOut: JSONEmpty(), }, "Delete rule: No command": { command: "", privilege: "", url: "/admin/rules/default/commands//privileges/basic", retErr: fmt.Errorf("an error"), expStatus: http.StatusBadRequest, expOut: JSONEmpty(), }, "Delete rule: No Privilege Path": { command: "", privilege: "", url: "/admin/rules/default/commands/ls/privileges", retErr: fmt.Errorf("an error"), // This status is returned by gin before reaching any of our handlers // This is why the expected Output response formatting is different to // all other apis expStatus: http.StatusNotFound, expOut: StringEqual("404 page not found"), }, "Delete rule: No Privilege": { command: "", privilege: "", url: "/admin/rules/default/commands/ls/privileges/", retErr: fmt.Errorf("an error"), // This status is returned by gin before reaching any of our handlers // This is why the expected Output response formatting is different to // all other apis expStatus: http.StatusNotFound, expOut: StringEqual("404 page not found"), }, } for name, tc := range tests { tc := tc t.Run(name, func(t *testing.T) { t.Parallel() log := newLogger() mreng := deleteMock{ retDelete: tc.retRes, retErr: tc.retErr, } r := httptest.NewRecorder() _, ginEngine := getTestGinContext(r) _, err := New(ginEngine, &mreng, log) assert.NoError(t, err) req, err := http.NewRequest( http.MethodDelete, tc.url, nil, ) assert.NoError(t, err) ginEngine.ServeHTTP(r, req) assert.Equal(t, tc.expStatus, r.Result().StatusCode) assert.Equal(t, tc.command, mreng.command) assert.Equal(t, tc.privilege, mreng.privilege) tc.expOut(t, r.Body.String()) }) } } func TestReadNil(t *testing.T) { tests := map[string]struct { URL string payload any }{ "Read Rule": { URL: "/admin/rules/default/commands/ls", }, "Read Command": { URL: "/admin/commands/ls", }, "Read Priv": { URL: "/admin/privileges/basic", }, } for name, tc := range tests { t.Run(name, func(t *testing.T) { log := newLogger() t.Setenv("RCLI_RES_DATA_DIR", "./testdata") ruleseng := MockRulesEngine{} r := httptest.NewRecorder() _, ginEngine := getTestGinContext(r) _, err := New(ginEngine, &ruleseng, log) assert.Nil(t, err) req, err := http.NewRequest(http.MethodGet, tc.URL, nil) assert.NoError(t, err) ginEngine.ServeHTTP(r, req) assert.Equal(t, http.StatusOK, r.Result().StatusCode) assert.Equal(t, r.Body.String(), "null") }) } } 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) _, err := New(ginEngine, nil, logr.Discard(), tc.checks...) assert.NoError(t, err) req, err := http.NewRequest(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)) }) } }