diff --git a/api/mock/mock_userdatabase.go b/api/mock/mock_userdatabase.go new file mode 100644 index 0000000..e79bc3e --- /dev/null +++ b/api/mock/mock_userdatabase.go @@ -0,0 +1,91 @@ +// Code generated by mockery v1.0.0 +package mock + +import mock "github.com/stretchr/testify/mock" +import model "github.com/jmattheis/memo/model" + +// MockUserDatabase is an autogenerated mock type for the UserDatabase type +type MockUserDatabase struct { + mock.Mock +} + +// CreateUser provides a mock function with given fields: user +func (_m *MockUserDatabase) CreateUser(user *model.User) error { + ret := _m.Called(user) + + var r0 error + if rf, ok := ret.Get(0).(func(*model.User) error); ok { + r0 = rf(user) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// DeleteUserByID provides a mock function with given fields: id +func (_m *MockUserDatabase) DeleteUserByID(id uint) error { + ret := _m.Called(id) + + var r0 error + if rf, ok := ret.Get(0).(func(uint) error); ok { + r0 = rf(id) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// GetUserByID provides a mock function with given fields: id +func (_m *MockUserDatabase) GetUserByID(id uint) *model.User { + ret := _m.Called(id) + + var r0 *model.User + if rf, ok := ret.Get(0).(func(uint) *model.User); ok { + r0 = rf(id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.User) + } + } + + return r0 +} + +// GetUserByName provides a mock function with given fields: name +func (_m *MockUserDatabase) GetUserByName(name string) *model.User { + ret := _m.Called(name) + + var r0 *model.User + if rf, ok := ret.Get(0).(func(string) *model.User); ok { + r0 = rf(name) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.User) + } + } + + return r0 +} + +// GetUsers provides a mock function with given fields: +func (_m *MockUserDatabase) GetUsers() []*model.User { + ret := _m.Called() + + var r0 []*model.User + if rf, ok := ret.Get(0).(func() []*model.User); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.User) + } + } + + return r0 +} + +// UpdateUser provides a mock function with given fields: user +func (_m *MockUserDatabase) UpdateUser(user *model.User) { + _m.Called(user) +} diff --git a/api/user.go b/api/user.go new file mode 100644 index 0000000..4840697 --- /dev/null +++ b/api/user.go @@ -0,0 +1,152 @@ +package api + +import ( + "errors" + "strconv" + + "github.com/gin-gonic/gin" + "github.com/jmattheis/memo/auth" + "github.com/jmattheis/memo/model" +) + +type userResponse struct { + ID uint `json:"id"` + Name string `binding:"required" json:"name" query:"name" form:"name"` + Pass string `json:"pass,omitempty" form:"pass" query:"pass"` + Admin bool `json:"admin" form:"admin" query:"admin"` +} + +// The UserDatabase interface for encapsulating database access. +type UserDatabase interface { + GetUsers() []*model.User + GetUserByID(id uint) *model.User + GetUserByName(name string) *model.User + DeleteUserByID(id uint) error + UpdateUser(user *model.User) + CreateUser(user *model.User) error +} + +// The UserAPI provides handlers for managing users. +type UserAPI struct { + DB UserDatabase +} + +// GetUsers returns all the users +func (a *UserAPI) GetUsers(ctx *gin.Context) { + users := a.DB.GetUsers() + + var resp []*userResponse + for _, user := range users { + resp = append(resp, toExternal(user)) + } + + ctx.JSON(200, resp) +} + +// GetCurrentUser returns the current user +func (a *UserAPI) GetCurrentUser(ctx *gin.Context) { + user := a.DB.GetUserByID(auth.GetUserID(ctx)) + ctx.JSON(200, toExternal(user)) +} + +// CreateUser creates a user +func (a *UserAPI) CreateUser(ctx *gin.Context) { + user := userResponse{} + if err := ctx.Bind(&user); err == nil { + if len(user.Pass) == 0 { + ctx.AbortWithError(400, errors.New("password may not be empty")) + } else { + internal := toInternal(&user, []byte{}) + if a.DB.GetUserByName(internal.Name) == nil { + a.DB.CreateUser(internal) + } else { + ctx.AbortWithError(400, errors.New("username already exists")) + } + } + } +} + +// GetUserByID returns the user by id +func (a *UserAPI) GetUserByID(ctx *gin.Context) { + if id, err := toUInt(ctx.Param("id")); err == nil { + if user := a.DB.GetUserByID(uint(id)); user != nil { + ctx.JSON(200, toExternal(user)) + } else { + ctx.AbortWithError(404, errors.New("user does not exist")) + } + } else { + ctx.AbortWithError(400, errors.New("invalid id")) + } +} + +// DeleteUserByID deletes the user by id +func (a *UserAPI) DeleteUserByID(ctx *gin.Context) { + if id, err := toUInt(ctx.Param("id")); err == nil { + if user := a.DB.GetUserByID(id); user != nil { + a.DB.DeleteUserByID(id) + } else { + ctx.AbortWithError(404, errors.New("user does not exist")) + } + } else { + ctx.AbortWithError(400, errors.New("invalid id")) + } +} + +type userPassword struct { + Pass string `binding:"required" json:"pass" form:"pass" query:"pass" ` +} + +// ChangePassword changes the password from the current user +func (a *UserAPI) ChangePassword(ctx *gin.Context) { + pw := userPassword{} + if err := ctx.Bind(&pw); err == nil { + user := a.DB.GetUserByID(auth.GetUserID(ctx)) + user.Pass = auth.CreatePassword(pw.Pass) + a.DB.UpdateUser(user) + } +} + +// UpdateUserByID updates and user by id +func (a *UserAPI) UpdateUserByID(ctx *gin.Context) { + if id, err := toUInt(ctx.Param("id")); err == nil { + var user *userResponse + if err := ctx.Bind(&user); err == nil { + if oldUser := a.DB.GetUserByID(id); oldUser != nil { + internal := toInternal(user, oldUser.Pass) + internal.ID = id + a.DB.UpdateUser(internal) + ctx.JSON(200, toExternal(internal)) + } else { + ctx.AbortWithError(404, errors.New("user does not exist")) + } + } + } else { + ctx.AbortWithError(400, errors.New("invalid id")) + } +} + +func toUInt(id string) (uint, error) { + parsed, err := strconv.ParseUint(id, 10, 32) + return uint(parsed), err +} + +func toInternal(response *userResponse, pw []byte) *model.User { + user := &model.User{ + Name: response.Name, + Admin: response.Admin, + } + if response.Pass != "" { + user.Pass = auth.CreatePassword(response.Pass) + } else { + user.Pass = pw + } + return user +} + +func toExternal(internal *model.User) *userResponse { + return &userResponse{ + Name: internal.Name, + Admin: internal.Admin, + ID: internal.ID, + } +} diff --git a/api/user_test.go b/api/user_test.go new file mode 100644 index 0000000..8bc332b --- /dev/null +++ b/api/user_test.go @@ -0,0 +1,271 @@ +package api + +import ( + "fmt" + "io/ioutil" + "net/http/httptest" + "strings" + "testing" + + "github.com/bouk/monkey" + "github.com/gin-gonic/gin" + apimock "github.com/jmattheis/memo/api/mock" + "github.com/jmattheis/memo/auth" + "github.com/jmattheis/memo/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" +) + +var ( + adminUser = &model.User{ID: 1, Name: "jmattheis", Pass: []byte{1, 2}, Admin: true} + adminUserJSON = `{"id":1,"name":"jmattheis","admin":true}` + normalUser = &model.User{ID: 2, Name: "nicories", Pass: []byte{2, 3}, Admin: false} + normalUserJSON = `{"id":2,"name":"nicories","admin":false}` +) + +func TestUserSuite(t *testing.T) { + suite.Run(t, new(UserSuite)) +} + +type UserSuite struct { + suite.Suite + db *apimock.MockUserDatabase + a *UserAPI + ctx *gin.Context + recorder *httptest.ResponseRecorder +} + +func (s *UserSuite) BeforeTest(suiteName, testName string) { + gin.SetMode(gin.TestMode) + s.recorder = httptest.NewRecorder() + s.ctx, _ = gin.CreateTestContext(s.recorder) + s.db = &apimock.MockUserDatabase{} + s.a = &UserAPI{DB: s.db} +} + +func (s *UserSuite) Test_GetUsers() { + s.db.On("GetUsers").Return([]*model.User{adminUser, normalUser}) + + s.a.GetUsers(s.ctx) + + s.expectJSON(fmt.Sprintf("[%s, %s]", adminUserJSON, normalUserJSON)) +} + +func (s *UserSuite) Test_GetCurrentUser() { + patch := monkey.Patch(auth.GetUserID, func(*gin.Context) uint { return 1 }) + defer patch.Unpatch() + s.db.On("GetUserByID", uint(1)).Return(adminUser) + + s.a.GetCurrentUser(s.ctx) + + s.expectJSON(adminUserJSON) +} + +func (s *UserSuite) Test_GetUserByID() { + s.db.On("GetUserByID", uint(2)).Return(normalUser) + s.ctx.Params = gin.Params{{Key: "id", Value: "2"}} + + s.a.GetUserByID(s.ctx) + + s.expectJSON(normalUserJSON) +} + +func (s *UserSuite) Test_GetUserByID_InvalidID() { + s.db.On("GetUserByID", uint(2)).Return(normalUser) + s.ctx.Params = gin.Params{{Key: "id", Value: "abc"}} + + s.a.GetUserByID(s.ctx) + + assert.Equal(s.T(), 400, s.recorder.Code) +} + +func (s *UserSuite) Test_GetUserByID_UnknownUser() { + s.db.On("GetUserByID", mock.Anything).Return(nil) + s.ctx.Params = gin.Params{{Key: "id", Value: "3"}} + + s.a.GetUserByID(s.ctx) + + assert.Equal(s.T(), 404, s.recorder.Code) +} + +func (s *UserSuite) Test_DeleteUserByID_InvalidID() { + s.ctx.Params = gin.Params{{Key: "id", Value: "abc"}} + + s.a.DeleteUserByID(s.ctx) + + assert.Equal(s.T(), 400, s.recorder.Code) +} + +func (s *UserSuite) Test_DeleteUserByID_UnknownUser() { + s.db.On("GetUserByID", mock.Anything).Return(nil) + s.ctx.Params = gin.Params{{Key: "id", Value: "3"}} + + s.a.DeleteUserByID(s.ctx) + + assert.Equal(s.T(), 404, s.recorder.Code) +} + +func (s *UserSuite) Test_DeleteUserByID() { + s.db.On("GetUserByID", uint(2)).Return(normalUser) + s.db.On("DeleteUserByID", uint(2)).Return(nil) + s.ctx.Params = gin.Params{{Key: "id", Value: "2"}} + + s.a.DeleteUserByID(s.ctx) + + s.db.AssertCalled(s.T(), "DeleteUserByID", uint(2)) + assert.Equal(s.T(), 200, s.recorder.Code) +} + +func (s *UserSuite) Test_CreateUser() { + pwByte := []byte{1, 2, 3} + patch := monkey.Patch(auth.CreatePassword, func(pw string) []byte { + if pw == "mylittlepony" { + return pwByte + } + return []byte{5, 67} + }) + defer patch.Unpatch() + + s.db.On("GetUserByName", "tom").Return(nil) + s.db.On("CreateUser", mock.Anything).Return(nil) + + s.ctx.Request = httptest.NewRequest("POST", "/user", strings.NewReader(`{"name": "tom", "pass": "mylittlepony", "admin": true}`)) + s.ctx.Request.Header.Set("Content-Type", "application/json") + + s.a.CreateUser(s.ctx) + + s.db.AssertCalled(s.T(), "CreateUser", &model.User{Name: "tom", Pass: pwByte, Admin: true}) +} + +func (s *UserSuite) Test_CreateUser_NoPassword() { + s.ctx.Request = httptest.NewRequest("POST", "/user", strings.NewReader(`{"name": "tom", "pass": "", "admin": true}`)) + s.ctx.Request.Header.Set("Content-Type", "application/json") + + s.a.CreateUser(s.ctx) + + assert.Equal(s.T(), 400, s.recorder.Code) +} + +func (s *UserSuite) Test_CreateUser_NoName() { + s.ctx.Request = httptest.NewRequest("POST", "/user", strings.NewReader(`{"name": "", "pass": "asd", "admin": true}`)) + s.ctx.Request.Header.Set("Content-Type", "application/json") + + s.a.CreateUser(s.ctx) + + assert.Equal(s.T(), 400, s.recorder.Code) +} + +func (s *UserSuite) Test_CreateUser_NameAlreadyExists() { + pwByte := []byte{1, 2, 3} + monkey.Patch(auth.CreatePassword, func(pw string) []byte { return pwByte }) + + s.db.On("GetUserByName", "tom").Return(&model.User{ID: 3, Name: "tom"}) + + s.ctx.Request = httptest.NewRequest("POST", "/user", strings.NewReader(`{"name": "tom", "pass": "mylittlepony", "admin": true}`)) + s.ctx.Request.Header.Set("Content-Type", "application/json") + + s.a.CreateUser(s.ctx) + + assert.Equal(s.T(), 400, s.recorder.Code) +} + +func (s *UserSuite) Test_UpdateUserByID_InvalidID() { + s.ctx.Params = gin.Params{{Key: "id", Value: "abc"}} + + s.ctx.Request = httptest.NewRequest("POST", "/user/abc", strings.NewReader(`{"name": "tom", "pass": "", "admin": false}`)) + s.ctx.Request.Header.Set("Content-Type", "application/json") + + s.a.UpdateUserByID(s.ctx) + + assert.Equal(s.T(), 400, s.recorder.Code) +} + +func (s *UserSuite) Test_UpdateUserByID_UnknownUser() { + s.db.On("GetUserByID", uint(2)).Return(nil) + + s.ctx.Params = gin.Params{{Key: "id", Value: "2"}} + + s.ctx.Request = httptest.NewRequest("POST", "/user/2", strings.NewReader(`{"name": "tom", "pass": "", "admin": false}`)) + s.ctx.Request.Header.Set("Content-Type", "application/json") + + s.a.UpdateUserByID(s.ctx) + + assert.Equal(s.T(), 404, s.recorder.Code) +} + +func (s *UserSuite) Test_UpdateUserByID_UpdateNotPassword() { + s.db.On("GetUserByID", uint(2)).Return(&model.User{Name: "nico", Pass: []byte{5}, Admin: false}) + expected := &model.User{ID: 2, Name: "tom", Pass: []byte{5}, Admin: true} + + s.db.On("UpdateUser", expected).Return(nil) + + s.ctx.Params = gin.Params{{Key: "id", Value: "2"}} + + s.ctx.Request = httptest.NewRequest("POST", "/user/2", strings.NewReader(`{"name": "tom", "pass": "", "admin": true}`)) + s.ctx.Request.Header.Set("Content-Type", "application/json") + + s.a.UpdateUserByID(s.ctx) + + assert.Equal(s.T(), 200, s.recorder.Code) + s.db.AssertCalled(s.T(), "UpdateUser", expected) +} + +func (s *UserSuite) Test_UpdateUserByID_UpdatePassword() { + pwByte := []byte{1, 2, 3} + patch := monkey.Patch(auth.CreatePassword, func(pw string) []byte { return pwByte }) + defer patch.Unpatch() + + s.db.On("GetUserByID", uint(2)).Return(normalUser) + expected := &model.User{ID: 2, Name: "tom", Pass: pwByte, Admin: true} + + s.db.On("UpdateUser", expected).Return(nil) + + s.ctx.Params = gin.Params{{Key: "id", Value: "2"}} + + s.ctx.Request = httptest.NewRequest("POST", "/user/2", strings.NewReader(`{"name": "tom", "pass": "secret", "admin": true}`)) + s.ctx.Request.Header.Set("Content-Type", "application/json") + + s.a.UpdateUserByID(s.ctx) + + assert.Equal(s.T(), 200, s.recorder.Code) + s.db.AssertCalled(s.T(), "UpdateUser", expected) +} + +func (s *UserSuite) Test_UpdatePassword() { + pwByte := []byte{1, 2, 3} + createPasswordPatch := monkey.Patch(auth.CreatePassword, func(pw string) []byte { return pwByte }) + defer createPasswordPatch.Unpatch() + patchUser := monkey.Patch(auth.GetUserID, func(*gin.Context) uint { return 1 }) + defer patchUser.Unpatch() + s.ctx.Request = httptest.NewRequest("POST", "/user/current/password", strings.NewReader(`{"pass": "secret"}`)) + s.ctx.Request.Header.Set("Content-Type", "application/json") + s.db.On("GetUserByID", uint(1)).Return(&model.User{ID: 1, Name: "jmattheis", Pass: []byte{1}}) + s.db.On("UpdateUser", mock.Anything).Return(nil) + + s.a.ChangePassword(s.ctx) + + s.db.AssertCalled(s.T(), "UpdateUser", &model.User{ID: 1, Name: "jmattheis", Pass: pwByte}) +} + +func (s *UserSuite) Test_UpdatePassword_EmptyPassword() { + patchUser := monkey.Patch(auth.GetUserID, func(*gin.Context) uint { return 1 }) + defer patchUser.Unpatch() + s.ctx.Request = httptest.NewRequest("POST", "/user/current/password", strings.NewReader(`{"pass":""}`)) + s.ctx.Request.Header.Set("Content-Type", "application/json") + s.db.On("UpdateUser", mock.Anything).Return(nil) + + s.db.On("GetUserByID", uint(1)).Return(&model.User{ID: 1, Name: "jmattheis", Pass: []byte{1}}) + + s.a.ChangePassword(s.ctx) + + s.db.AssertNotCalled(s.T(), "UpdateUser", mock.Anything) + assert.Equal(s.T(), 400, s.recorder.Code) +} + +func (s *UserSuite) expectJSON(json string) { + assert.Equal(s.T(), 200, s.recorder.Code) + bytes, _ := ioutil.ReadAll(s.recorder.Body) + + assert.JSONEq(s.T(), json, string(bytes)) +} diff --git a/model/user.go b/model/user.go index e6dd496..7e8786f 100644 --- a/model/user.go +++ b/model/user.go @@ -3,7 +3,7 @@ package model // The User holds information about the credentials of a user and its application and client tokens. type User struct { ID uint `gorm:"primary_key;unique_index;AUTO_INCREMENT"` - Name string + Name string `gorm:"unique_index"` Pass []byte Admin bool Applications []Application