diff --git a/api/token.go b/api/application.go similarity index 60% rename from api/token.go rename to api/application.go index 3ef9212..13146c2 100644 --- a/api/token.go +++ b/api/application.go @@ -1,13 +1,11 @@ package api import ( - "fmt" - "errors" + "fmt" "net/http" - "path/filepath" - "os" + "path/filepath" "github.com/gin-gonic/gin" "github.com/gotify/location" @@ -16,31 +14,24 @@ import ( "github.com/h2non/filetype" ) -// The TokenDatabase interface for encapsulating database access. -type TokenDatabase interface { +// The ApplicationDatabase interface for encapsulating database access. +type ApplicationDatabase interface { CreateApplication(application *model.Application) error GetApplicationByToken(token string) *model.Application GetApplicationByID(id uint) *model.Application GetApplicationsByUser(userID uint) []*model.Application DeleteApplicationByID(id uint) error - UpdateApplication(application *model.Application) - - CreateClient(client *model.Client) error - GetClientByToken(token string) *model.Client - GetClientByID(id uint) *model.Client - GetClientsByUser(userID uint) []*model.Client - DeleteClientByID(id uint) error + UpdateApplication(application *model.Application) error } -// The TokenAPI provides handlers for managing clients and applications. -type TokenAPI struct { - DB TokenDatabase - ImageDir string - NotifyDeleted func(uint, string) +// The ApplicationAPI provides handlers for managing applications. +type ApplicationAPI struct { + DB ApplicationDatabase + ImageDir string } // CreateApplication creates an application and returns the access token. -func (a *TokenAPI) CreateApplication(ctx *gin.Context) { +func (a *ApplicationAPI) CreateApplication(ctx *gin.Context) { app := model.Application{} if err := ctx.Bind(&app); err == nil { app.Token = generateNotExistingToken(auth.GenerateApplicationToken, a.applicationExists) @@ -50,19 +41,8 @@ func (a *TokenAPI) CreateApplication(ctx *gin.Context) { } } -// CreateClient creates a client and returns the access token. -func (a *TokenAPI) CreateClient(ctx *gin.Context) { - client := model.Client{} - if err := ctx.Bind(&client); err == nil { - client.Token = generateNotExistingToken(auth.GenerateClientToken, a.clientExists) - client.UserID = auth.GetUserID(ctx) - a.DB.CreateClient(&client) - ctx.JSON(200, client) - } -} - // GetApplications returns all applications a user has. -func (a *TokenAPI) GetApplications(ctx *gin.Context) { +func (a *ApplicationAPI) GetApplications(ctx *gin.Context) { userID := auth.GetUserID(ctx) apps := a.DB.GetApplicationsByUser(userID) for _, app := range apps { @@ -71,15 +51,8 @@ func (a *TokenAPI) GetApplications(ctx *gin.Context) { ctx.JSON(200, apps) } -// GetClients returns all clients a user has. -func (a *TokenAPI) GetClients(ctx *gin.Context) { - userID := auth.GetUserID(ctx) - clients := a.DB.GetClientsByUser(userID) - ctx.JSON(200, clients) -} - // DeleteApplication deletes an application by its id. -func (a *TokenAPI) DeleteApplication(ctx *gin.Context) { +func (a *ApplicationAPI) DeleteApplication(ctx *gin.Context) { withID(ctx, "id", func(id uint) { if app := a.DB.GetApplicationByID(id); app != nil && app.UserID == auth.GetUserID(ctx) { a.DB.DeleteApplicationByID(id) @@ -92,20 +65,27 @@ func (a *TokenAPI) DeleteApplication(ctx *gin.Context) { }) } -// DeleteClient deletes a client by its id. -func (a *TokenAPI) DeleteClient(ctx *gin.Context) { +// UpdateApplication updates an application info by its id. +func (a *ApplicationAPI) UpdateApplication(ctx *gin.Context) { withID(ctx, "id", func(id uint) { - if client := a.DB.GetClientByID(id); client != nil && client.UserID == auth.GetUserID(ctx) { - a.NotifyDeleted(client.UserID, client.Token) - a.DB.DeleteClientByID(id) + if app := a.DB.GetApplicationByID(id); app != nil && app.UserID == auth.GetUserID(ctx) { + newValues := &model.Application{} + if err := ctx.Bind(newValues); err == nil { + app.Description = newValues.Description + app.Name = newValues.Name + + a.DB.UpdateApplication(app) + + ctx.JSON(200, withAbsoluteURL(ctx, app)) + } } else { - ctx.AbortWithError(404, fmt.Errorf("client with id %d doesn't exists", id)) + ctx.AbortWithError(404, fmt.Errorf("app with id %d doesn't exists", id)) } }) } // UploadApplicationImage uploads an image for an application. -func (a *TokenAPI) UploadApplicationImage(ctx *gin.Context) { +func (a *ApplicationAPI) UploadApplicationImage(ctx *gin.Context) { withID(ctx, "id", func(id uint) { if app := a.DB.GetApplicationByID(id); app != nil && app.UserID == auth.GetUserID(ctx) { file, err := ctx.FormFile("file") @@ -150,6 +130,10 @@ func (a *TokenAPI) UploadApplicationImage(ctx *gin.Context) { }) } +func (a *ApplicationAPI) applicationExists(token string) bool { + return a.DB.GetApplicationByToken(token) != nil +} + func exist(path string) bool { if _, err := os.Stat(path); os.IsNotExist(err) { return false @@ -168,20 +152,3 @@ func withAbsoluteURL(ctx *gin.Context, app *model.Application) *model.Applicatio app.Image = url.String() return app } - -func (a *TokenAPI) applicationExists(token string) bool { - return a.DB.GetApplicationByToken(token) != nil -} - -func (a *TokenAPI) clientExists(token string) bool { - return a.DB.GetClientByToken(token) != nil -} - -func generateNotExistingToken(generateToken func() string, tokenExists func(token string) bool) string { - for { - token := generateToken() - if !tokenExists(token) { - return token - } - } -} diff --git a/api/token_test.go b/api/application_test.go similarity index 65% rename from api/token_test.go rename to api/application_test.go index 60d2b59..b0defb5 100644 --- a/api/token_test.go +++ b/api/application_test.go @@ -12,7 +12,6 @@ import ( "io" "io/ioutil" "mime/multipart" - "net/url" "os" "github.com/gin-gonic/gin" @@ -26,57 +25,63 @@ import ( var ( firstApplicationToken = "APorrUa5b1IIK3y" secondApplicationToken = "AKo_Pp6ww_9vZal" - firstClientToken = "CPorrUa5b1IIK3y" - secondClientToken = "CKo_Pp6ww_9vZal" ) -func TestTokenSuite(t *testing.T) { - suite.Run(t, new(TokenSuite)) +func TestApplicationSuite(t *testing.T) { + suite.Run(t, new(ApplicationSuite)) } -type TokenSuite struct { +type ApplicationSuite struct { suite.Suite db *test.Database - a *TokenAPI + a *ApplicationAPI ctx *gin.Context recorder *httptest.ResponseRecorder - notified bool } -func (s *TokenSuite) BeforeTest(suiteName, testName string) { +func (s *ApplicationSuite) BeforeTest(suiteName, testName string) { mode.Set(mode.TestDev) rand.Seed(50) s.recorder = httptest.NewRecorder() s.db = test.NewDB(s.T()) s.ctx, _ = gin.CreateTestContext(s.recorder) withURL(s.ctx, "http", "example.com") - s.notified = false - s.a = &TokenAPI{DB: s.db, NotifyDeleted: s.notify} + s.a = &ApplicationAPI{DB: s.db} } -func (s *TokenSuite) notify(uint, string) { - s.notified = true -} - -func (s *TokenSuite) AfterTest(suiteName, testName string) { +func (s *ApplicationSuite) AfterTest(suiteName, testName string) { s.db.Close() } -// test application api - -func (s *TokenSuite) Test_CreateApplication_mapAllParameters() { +func (s *ApplicationSuite) Test_CreateApplication_mapAllParameters() { s.db.User(5) test.WithUser(s.ctx, 5) s.withFormData("name=custom_name&description=description_text") s.a.CreateApplication(s.ctx) - expected := &model.Application{ID: 1, Token: firstApplicationToken, UserID: 5, Name: "custom_name", Description: "description_text"} + expected := &model.Application{ + ID: 1, + Token: firstApplicationToken, + UserID: 5, + Name: "custom_name", + Description: "description_text", + } assert.Equal(s.T(), 200, s.recorder.Code) assert.Equal(s.T(), expected, s.db.GetApplicationByID(1)) } - -func (s *TokenSuite) Test_CreateApplication_expectBadRequestOnEmptyName() { +func (s *ApplicationSuite) Test_ensureApplicationHasCorrectJsonRepresentation() { + actual := &model.Application{ + ID: 1, + UserID: 2, + Token: "Aasdasfgeeg", + Name: "myapp", + Description: "mydesc", + Image: "asd", + } + test.JSONEquals(s.T(), actual, `{"id":1,"token":"Aasdasfgeeg","name":"myapp","description":"mydesc", "image": "asd"}`) +} +func (s *ApplicationSuite) Test_CreateApplication_expectBadRequestOnEmptyName() { s.db.User(5) test.WithUser(s.ctx, 5) @@ -87,7 +92,7 @@ func (s *TokenSuite) Test_CreateApplication_expectBadRequestOnEmptyName() { assert.Empty(s.T(), s.db.GetApplicationsByUser(5)) } -func (s *TokenSuite) Test_DeleteApplication_expectNotFoundOnCurrentUserIsNotOwner() { +func (s *ApplicationSuite) Test_DeleteApplication_expectNotFoundOnCurrentUserIsNotOwner() { s.db.User(2) s.db.User(5).App(5) @@ -101,7 +106,7 @@ func (s *TokenSuite) Test_DeleteApplication_expectNotFoundOnCurrentUserIsNotOwne s.db.AssertAppExist(5) } -func (s *TokenSuite) Test_CreateApplication_onlyRequiredParameters() { +func (s *ApplicationSuite) Test_CreateApplication_onlyRequiredParameters() { s.db.User(5) test.WithUser(s.ctx, 5) @@ -112,12 +117,8 @@ func (s *TokenSuite) Test_CreateApplication_onlyRequiredParameters() { assert.Equal(s.T(), 200, s.recorder.Code) assert.Contains(s.T(), s.db.GetApplicationsByUser(5), expected) } -func (s *TokenSuite) Test_ensureApplicationHasCorrectJsonRepresentation() { - actual := &model.Application{ID: 1, UserID: 2, Token: "Aasdasfgeeg", Name: "myapp", Description: "mydesc", Image: "asd"} - test.JSONEquals(s.T(), actual, `{"id":1,"token":"Aasdasfgeeg","name":"myapp","description":"mydesc", "image": "asd"}`) -} -func (s *TokenSuite) Test_CreateApplication_returnsApplicationWithID() { +func (s *ApplicationSuite) Test_CreateApplication_returnsApplicationWithID() { s.db.User(5) test.WithUser(s.ctx, 5) @@ -125,12 +126,18 @@ func (s *TokenSuite) Test_CreateApplication_returnsApplicationWithID() { s.a.CreateApplication(s.ctx) - expected := &model.Application{ID: 1, Token: firstApplicationToken, Name: "custom_name", Image: "http://example.com/static/defaultapp.png", UserID: 5} + expected := &model.Application{ + ID: 1, + Token: firstApplicationToken, + Name: "custom_name", + Image: "http://example.com/static/defaultapp.png", + UserID: 5, + } assert.Equal(s.T(), 200, s.recorder.Code) test.BodyEquals(s.T(), expected, s.recorder) } -func (s *TokenSuite) Test_CreateApplication_withExistingToken() { +func (s *ApplicationSuite) Test_CreateApplication_withExistingToken() { s.db.User(5) s.db.User(6).AppWithToken(1, firstApplicationToken) @@ -144,7 +151,7 @@ func (s *TokenSuite) Test_CreateApplication_withExistingToken() { assert.Contains(s.T(), s.db.GetApplicationsByUser(5), expected) } -func (s *TokenSuite) Test_GetApplications() { +func (s *ApplicationSuite) Test_GetApplications() { userBuilder := s.db.User(5) first := userBuilder.NewAppWithToken(1, "perfper") second := userBuilder.NewAppWithToken(2, "asdasd") @@ -160,7 +167,7 @@ func (s *TokenSuite) Test_GetApplications() { test.BodyEquals(s.T(), []*model.Application{first, second}, s.recorder) } -func (s *TokenSuite) Test_GetApplications_WithImage() { +func (s *ApplicationSuite) Test_GetApplications_WithImage() { userBuilder := s.db.User(5) first := userBuilder.NewAppWithToken(1, "perfper") second := userBuilder.NewAppWithToken(2, "asdasd") @@ -178,7 +185,7 @@ func (s *TokenSuite) Test_GetApplications_WithImage() { test.BodyEquals(s.T(), []*model.Application{first, second}, s.recorder) } -func (s *TokenSuite) Test_DeleteApplication_expectNotFound() { +func (s *ApplicationSuite) Test_DeleteApplication_expectNotFound() { s.db.User(5) test.WithUser(s.ctx, 5) @@ -190,7 +197,7 @@ func (s *TokenSuite) Test_DeleteApplication_expectNotFound() { assert.Equal(s.T(), 404, s.recorder.Code) } -func (s *TokenSuite) Test_DeleteApplication() { +func (s *ApplicationSuite) Test_DeleteApplication() { s.db.User(5).App(1) test.WithUser(s.ctx, 5) @@ -203,122 +210,7 @@ func (s *TokenSuite) Test_DeleteApplication() { s.db.AssertAppNotExist(1) } -// test client api - -func (s *TokenSuite) Test_ensureClientHasCorrectJsonRepresentation() { - actual := &model.Client{ID: 1, UserID: 2, Token: "Casdasfgeeg", Name: "myclient"} - test.JSONEquals(s.T(), actual, `{"id":1,"token":"Casdasfgeeg","name":"myclient"}`) -} - -func (s *TokenSuite) Test_CreateClient_mapAllParameters() { - s.db.User(5) - - test.WithUser(s.ctx, 5) - s.withFormData("name=custom_name&description=description_text") - - s.a.CreateClient(s.ctx) - - expected := &model.Client{ID: 1, Token: firstClientToken, UserID: 5, Name: "custom_name"} - assert.Equal(s.T(), 200, s.recorder.Code) - assert.Contains(s.T(), s.db.GetClientsByUser(5), expected) -} - -func (s *TokenSuite) Test_CreateClient_expectBadRequestOnEmptyName() { - s.db.User(5) - - test.WithUser(s.ctx, 5) - s.withFormData("name=&description=description_text") - - s.a.CreateClient(s.ctx) - - assert.Equal(s.T(), 400, s.recorder.Code) - assert.Empty(s.T(), s.db.GetClientsByUser(5)) -} - -func (s *TokenSuite) Test_DeleteClient_expectNotFoundOnCurrentUserIsNotOwner() { - s.db.User(5).Client(7) - s.db.User(2) - - test.WithUser(s.ctx, 2) - s.ctx.Request = httptest.NewRequest("DELETE", "/token/7", nil) - s.ctx.Params = gin.Params{{Key: "id", Value: "7"}} - - s.a.DeleteClient(s.ctx) - - assert.Equal(s.T(), 404, s.recorder.Code) - s.db.AssertClientExist(7) -} - -func (s *TokenSuite) Test_CreateClient_returnsClientWithID() { - s.db.User(5) - - test.WithUser(s.ctx, 5) - s.withFormData("name=custom_name") - - s.a.CreateClient(s.ctx) - - expected := &model.Client{ID: 1, Token: firstClientToken, Name: "custom_name", UserID: 5} - assert.Equal(s.T(), 200, s.recorder.Code) - test.BodyEquals(s.T(), expected, s.recorder) -} - -func (s *TokenSuite) Test_CreateClient_withExistingToken() { - s.db.User(5).ClientWithToken(1, firstClientToken) - - test.WithUser(s.ctx, 5) - s.withFormData("name=custom_name") - - s.a.CreateClient(s.ctx) - - expected := &model.Client{ID: 2, Token: secondClientToken, Name: "custom_name", UserID: 5} - assert.Equal(s.T(), 200, s.recorder.Code) - test.BodyEquals(s.T(), expected, s.recorder) -} - -func (s *TokenSuite) Test_GetClients() { - userBuilder := s.db.User(5) - first := userBuilder.NewClientWithToken(1, "perfper") - second := userBuilder.NewClientWithToken(2, "asdasd") - - test.WithUser(s.ctx, 5) - s.ctx.Request = httptest.NewRequest("GET", "/tokens", nil) - - s.a.GetClients(s.ctx) - - assert.Equal(s.T(), 200, s.recorder.Code) - test.BodyEquals(s.T(), []*model.Client{first, second}, s.recorder) -} - -func (s *TokenSuite) Test_DeleteClient_expectNotFound() { - s.db.User(5) - - test.WithUser(s.ctx, 5) - s.ctx.Request = httptest.NewRequest("DELETE", "/token/"+firstClientToken, nil) - s.ctx.Params = gin.Params{{Key: "id", Value: "8"}} - - s.a.DeleteClient(s.ctx) - - assert.Equal(s.T(), 404, s.recorder.Code) -} - -// -func (s *TokenSuite) Test_DeleteClient() { - s.db.User(5).Client(8) - - test.WithUser(s.ctx, 5) - s.ctx.Request = httptest.NewRequest("DELETE", "/token/"+firstClientToken, nil) - s.ctx.Params = gin.Params{{Key: "id", Value: "8"}} - - assert.False(s.T(), s.notified) - - s.a.DeleteClient(s.ctx) - - assert.Equal(s.T(), 200, s.recorder.Code) - s.db.AssertClientNotExist(8) - assert.True(s.T(), s.notified) -} - -func (s *TokenSuite) Test_UploadAppImage_NoImageProvided_expectBadRequest() { +func (s *ApplicationSuite) Test_UploadAppImage_NoImageProvided_expectBadRequest() { s.db.User(5).App(1) var b bytes.Buffer writer := multipart.NewWriter(&b) @@ -335,7 +227,7 @@ func (s *TokenSuite) Test_UploadAppImage_NoImageProvided_expectBadRequest() { assert.Equal(s.T(), s.ctx.Errors[0].Err, errors.New("file with key 'file' must be present")) } -func (s *TokenSuite) Test_UploadAppImage_OtherErrors_expectServerError() { +func (s *ApplicationSuite) Test_UploadAppImage_OtherErrors_expectServerError() { s.db.User(5).App(1) var b bytes.Buffer writer := multipart.NewWriter(&b) @@ -352,7 +244,7 @@ func (s *TokenSuite) Test_UploadAppImage_OtherErrors_expectServerError() { assert.Equal(s.T(), s.ctx.Errors[0].Err, errors.New("multipart: NextPart: EOF")) } -func (s *TokenSuite) Test_UploadAppImage_WithImageFile_expectSuccess() { +func (s *ApplicationSuite) Test_UploadAppImage_WithImageFile_expectSuccess() { s.db.User(5).App(1) cType, buffer, err := upload(map[string]*os.File{"file": mustOpen("../test/assets/image.png")}) @@ -374,7 +266,7 @@ func (s *TokenSuite) Test_UploadAppImage_WithImageFile_expectSuccess() { assert.True(s.T(), os.IsNotExist(err)) } -func (s *TokenSuite) Test_UploadAppImage_WithImageFile_DeleteExstingImageAndGenerateNewName() { +func (s *ApplicationSuite) Test_UploadAppImage_WithImageFile_DeleteExstingImageAndGenerateNewName() { s.db.User(5) s.db.CreateApplication(&model.Application{UserID: 5, ID: 1, Image: "PorrUa5b1IIK3yKo_Pp6ww_9v.png"}) @@ -397,7 +289,7 @@ func (s *TokenSuite) Test_UploadAppImage_WithImageFile_DeleteExstingImageAndGene assert.Nil(s.T(), os.Remove("Zal6-ySIuL-T3EMLCcFtityHn.png")) } -func (s *TokenSuite) Test_UploadAppImage_WithImageFile_DeleteExistingImage() { +func (s *ApplicationSuite) Test_UploadAppImage_WithImageFile_DeleteExistingImage() { s.db.User(5) s.db.CreateApplication(&model.Application{UserID: 5, ID: 1, Image: "existing.png"}) @@ -419,7 +311,7 @@ func (s *TokenSuite) Test_UploadAppImage_WithImageFile_DeleteExistingImage() { os.Remove("PorrUa5b1IIK3yKo_Pp6ww_9v.png") } -func (s *TokenSuite) Test_UploadAppImage_WithTextFile_expectBadRequest() { +func (s *ApplicationSuite) Test_UploadAppImage_WithTextFile_expectBadRequest() { s.db.User(5).App(1) cType, buffer, err := upload(map[string]*os.File{"file": mustOpen("../test/assets/text.txt")}) @@ -435,7 +327,7 @@ func (s *TokenSuite) Test_UploadAppImage_WithTextFile_expectBadRequest() { assert.Equal(s.T(), s.ctx.Errors[0].Err, errors.New("file must be an image")) } -func (s *TokenSuite) Test_UploadAppImage_expectNotFound() { +func (s *ApplicationSuite) Test_UploadAppImage_expectNotFound() { s.db.User(5) test.WithUser(s.ctx, 5) @@ -447,7 +339,7 @@ func (s *TokenSuite) Test_UploadAppImage_expectNotFound() { assert.Equal(s.T(), 404, s.recorder.Code) } -func (s *TokenSuite) Test_UploadAppImage_WithSaveError_expectServerError() { +func (s *ApplicationSuite) Test_UploadAppImage_WithSaveError_expectServerError() { s.db.User(5).App(1) cType, buffer, err := upload(map[string]*os.File{"file": mustOpen("../test/assets/image.png")}) @@ -463,13 +355,105 @@ func (s *TokenSuite) Test_UploadAppImage_WithSaveError_expectServerError() { assert.Equal(s.T(), 500, s.recorder.Code) } -func (s *TokenSuite) withFormData(formData string) { - s.ctx.Request = httptest.NewRequest("POST", "/token", strings.NewReader(formData)) - s.ctx.Request.Header.Set("Content-Type", "application/x-www-form-urlencoded") +func (s *ApplicationSuite) Test_UpdateApplicationNameAndDescription_expectSuccess() { + s.db.User(5).NewAppWithToken(2, "app-2") + + test.WithUser(s.ctx, 5) + s.withFormData("name=new_name&description=new_description_text") + s.ctx.Params = gin.Params{{Key: "id", Value: "2"}} + s.a.UpdateApplication(s.ctx) + + expected := &model.Application{ + ID: 2, + Token: "app-2", + UserID: 5, + Name: "new_name", + Description: "new_description_text", + } + + assert.Equal(s.T(), 200, s.recorder.Code) + assert.Equal(s.T(), expected, s.db.GetApplicationByID(2)) } -func withURL(ctx *gin.Context, scheme, host string) { - ctx.Set("location", &url.URL{Scheme: scheme, Host: host}) +func (s *ApplicationSuite) Test_UpdateApplicationName_expectSuccess() { + s.db.User(5).NewAppWithToken(2, "app-2") + + test.WithUser(s.ctx, 5) + s.withFormData("name=new_name") + s.ctx.Params = gin.Params{{Key: "id", Value: "2"}} + s.a.UpdateApplication(s.ctx) + + expected := &model.Application{ + ID: 2, + Token: "app-2", + UserID: 5, + Name: "new_name", + Description: "", + } + + assert.Equal(s.T(), 200, s.recorder.Code) + assert.Equal(s.T(), expected, s.db.GetApplicationByID(2)) +} + +func (s *ApplicationSuite) Test_UpdateApplication_preservesImage() { + app := s.db.User(5).NewAppWithToken(2, "app-2") + app.Image = "existing.png" + assert.Nil(s.T(), s.db.UpdateApplication(app)) + + test.WithUser(s.ctx, 5) + s.withFormData("name=new_name") + s.ctx.Params = gin.Params{{Key: "id", Value: "2"}} + + s.a.UpdateApplication(s.ctx) + + assert.Equal(s.T(), 200, s.recorder.Code) + assert.Equal(s.T(), "existing.png", s.db.GetApplicationByID(2).Image) +} + +func (s *ApplicationSuite) Test_UpdateApplication_setEmptyDescription() { + app := s.db.User(5).NewAppWithToken(2, "app-2") + app.Description = "my desc" + assert.Nil(s.T(), s.db.UpdateApplication(app)) + + test.WithUser(s.ctx, 5) + s.withFormData("name=new_name&desc=") + s.ctx.Params = gin.Params{{Key: "id", Value: "2"}} + + s.a.UpdateApplication(s.ctx) + + assert.Equal(s.T(), 200, s.recorder.Code) + assert.Equal(s.T(), "", s.db.GetApplicationByID(2).Description) +} + +func (s *ApplicationSuite) Test_UpdateApplication_expectNotFound() { + test.WithUser(s.ctx, 5) + s.ctx.Params = gin.Params{{Key: "id", Value: "2"}} + s.a.UpdateApplication(s.ctx) + + assert.Equal(s.T(), 404, s.recorder.Code) +} + +func (s *ApplicationSuite) Test_UpdateApplication_WithMissingAttributes_expectBadRequest() { + test.WithUser(s.ctx, 5) + s.a.UpdateApplication(s.ctx) + + assert.Equal(s.T(), 400, s.recorder.Code) +} + +func (s *ApplicationSuite) Test_UpdateApplication_WithoutPermission_expectNotFound() { + s.db.User(5).NewAppWithToken(2, "app-2") + + test.WithUser(s.ctx, 4) + s.ctx.Params = gin.Params{{Key: "id", Value: "2"}} + + s.a.UpdateApplication(s.ctx) + + assert.Equal(s.T(), 404, s.recorder.Code) +} + +func (s *ApplicationSuite) withFormData(formData string) { + s.ctx.Request = httptest.NewRequest("POST", "/token", strings.NewReader(formData)) + s.ctx.Request.Header.Set("Content-Type", "application/x-www-form-urlencoded") } // A modified version of https://stackoverflow.com/a/20397167/4244993 from Attila O. diff --git a/api/client.go b/api/client.go new file mode 100644 index 0000000..0d9a229 --- /dev/null +++ b/api/client.go @@ -0,0 +1,68 @@ +package api + +import ( + "fmt" + + "github.com/gin-gonic/gin" + "github.com/gotify/server/auth" + "github.com/gotify/server/model" +) + +// The ClientDatabase interface for encapsulating database access. +type ClientDatabase interface { + CreateClient(client *model.Client) error + GetClientByToken(token string) *model.Client + GetClientByID(id uint) *model.Client + GetClientsByUser(userID uint) []*model.Client + DeleteClientByID(id uint) error +} + +// The ClientAPI provides handlers for managing clients and applications. +type ClientAPI struct { + DB ClientDatabase + ImageDir string + NotifyDeleted func(uint, string) +} + +// CreateClient creates a client and returns the access token. +func (a *ClientAPI) CreateClient(ctx *gin.Context) { + client := model.Client{} + if err := ctx.Bind(&client); err == nil { + client.Token = generateNotExistingToken(auth.GenerateClientToken, a.clientExists) + client.UserID = auth.GetUserID(ctx) + a.DB.CreateClient(&client) + ctx.JSON(200, client) + } +} + +// GetClients returns all clients a user has. +func (a *ClientAPI) GetClients(ctx *gin.Context) { + userID := auth.GetUserID(ctx) + clients := a.DB.GetClientsByUser(userID) + ctx.JSON(200, clients) +} + +// DeleteClient deletes a client by its id. +func (a *ClientAPI) DeleteClient(ctx *gin.Context) { + withID(ctx, "id", func(id uint) { + if client := a.DB.GetClientByID(id); client != nil && client.UserID == auth.GetUserID(ctx) { + a.NotifyDeleted(client.UserID, client.Token) + a.DB.DeleteClientByID(id) + } else { + ctx.AbortWithError(404, fmt.Errorf("client with id %d doesn't exists", id)) + } + }) +} + +func (a *ClientAPI) clientExists(token string) bool { + return a.DB.GetClientByToken(token) != nil +} + +func generateNotExistingToken(generateToken func() string, tokenExists func(token string) bool) string { + for { + token := generateToken() + if !tokenExists(token) { + return token + } + } +} diff --git a/api/client_test.go b/api/client_test.go new file mode 100644 index 0000000..1a9d864 --- /dev/null +++ b/api/client_test.go @@ -0,0 +1,176 @@ +package api + +import ( + "math/rand" + "net/http/httptest" + "testing" + + "strings" + + "net/url" + + "github.com/gin-gonic/gin" + "github.com/gotify/server/mode" + "github.com/gotify/server/model" + "github.com/gotify/server/test" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" +) + +var ( + firstClientToken = "CPorrUa5b1IIK3y" + secondClientToken = "CKo_Pp6ww_9vZal" +) + +func TestClientSuite(t *testing.T) { + suite.Run(t, new(ClientSuite)) +} + +type ClientSuite struct { + suite.Suite + db *test.Database + a *ClientAPI + ctx *gin.Context + recorder *httptest.ResponseRecorder + notified bool +} + +func (s *ClientSuite) BeforeTest(suiteName, testName string) { + mode.Set(mode.TestDev) + rand.Seed(50) + s.recorder = httptest.NewRecorder() + s.db = test.NewDB(s.T()) + s.ctx, _ = gin.CreateTestContext(s.recorder) + withURL(s.ctx, "http", "example.com") + s.notified = false + s.a = &ClientAPI{DB: s.db, NotifyDeleted: s.notify} +} + +func (s *ClientSuite) notify(uint, string) { + s.notified = true +} + +func (s *ClientSuite) AfterTest(suiteName, testName string) { + s.db.Close() +} + +func (s *ClientSuite) Test_ensureClientHasCorrectJsonRepresentation() { + actual := &model.Client{ID: 1, UserID: 2, Token: "Casdasfgeeg", Name: "myclient"} + test.JSONEquals(s.T(), actual, `{"id":1,"token":"Casdasfgeeg","name":"myclient"}`) +} + +func (s *ClientSuite) Test_CreateClient_mapAllParameters() { + s.db.User(5) + + test.WithUser(s.ctx, 5) + s.withFormData("name=custom_name&description=description_text") + + s.a.CreateClient(s.ctx) + + expected := &model.Client{ID: 1, Token: firstClientToken, UserID: 5, Name: "custom_name"} + assert.Equal(s.T(), 200, s.recorder.Code) + assert.Contains(s.T(), s.db.GetClientsByUser(5), expected) +} + +func (s *ClientSuite) Test_CreateClient_expectBadRequestOnEmptyName() { + s.db.User(5) + + test.WithUser(s.ctx, 5) + s.withFormData("name=&description=description_text") + + s.a.CreateClient(s.ctx) + + assert.Equal(s.T(), 400, s.recorder.Code) + assert.Empty(s.T(), s.db.GetClientsByUser(5)) +} + +func (s *ClientSuite) Test_DeleteClient_expectNotFoundOnCurrentUserIsNotOwner() { + s.db.User(5).Client(7) + s.db.User(2) + + test.WithUser(s.ctx, 2) + s.ctx.Request = httptest.NewRequest("DELETE", "/token/7", nil) + s.ctx.Params = gin.Params{{Key: "id", Value: "7"}} + + s.a.DeleteClient(s.ctx) + + assert.Equal(s.T(), 404, s.recorder.Code) + s.db.AssertClientExist(7) +} + +func (s *ClientSuite) Test_CreateClient_returnsClientWithID() { + s.db.User(5) + + test.WithUser(s.ctx, 5) + s.withFormData("name=custom_name") + + s.a.CreateClient(s.ctx) + + expected := &model.Client{ID: 1, Token: firstClientToken, Name: "custom_name", UserID: 5} + assert.Equal(s.T(), 200, s.recorder.Code) + test.BodyEquals(s.T(), expected, s.recorder) +} + +func (s *ClientSuite) Test_CreateClient_withExistingToken() { + s.db.User(5).ClientWithToken(1, firstClientToken) + + test.WithUser(s.ctx, 5) + s.withFormData("name=custom_name") + + s.a.CreateClient(s.ctx) + + expected := &model.Client{ID: 2, Token: secondClientToken, Name: "custom_name", UserID: 5} + assert.Equal(s.T(), 200, s.recorder.Code) + test.BodyEquals(s.T(), expected, s.recorder) +} + +func (s *ClientSuite) Test_GetClients() { + userBuilder := s.db.User(5) + first := userBuilder.NewClientWithToken(1, "perfper") + second := userBuilder.NewClientWithToken(2, "asdasd") + + test.WithUser(s.ctx, 5) + s.ctx.Request = httptest.NewRequest("GET", "/tokens", nil) + + s.a.GetClients(s.ctx) + + assert.Equal(s.T(), 200, s.recorder.Code) + test.BodyEquals(s.T(), []*model.Client{first, second}, s.recorder) +} + +func (s *ClientSuite) Test_DeleteClient_expectNotFound() { + s.db.User(5) + + test.WithUser(s.ctx, 5) + s.ctx.Request = httptest.NewRequest("DELETE", "/token/"+firstClientToken, nil) + s.ctx.Params = gin.Params{{Key: "id", Value: "8"}} + + s.a.DeleteClient(s.ctx) + + assert.Equal(s.T(), 404, s.recorder.Code) +} + +func (s *ClientSuite) Test_DeleteClient() { + s.db.User(5).Client(8) + + test.WithUser(s.ctx, 5) + s.ctx.Request = httptest.NewRequest("DELETE", "/token/"+firstClientToken, nil) + s.ctx.Params = gin.Params{{Key: "id", Value: "8"}} + + assert.False(s.T(), s.notified) + + s.a.DeleteClient(s.ctx) + + assert.Equal(s.T(), 200, s.recorder.Code) + s.db.AssertClientNotExist(8) + assert.True(s.T(), s.notified) +} + +func (s *ClientSuite) withFormData(formData string) { + s.ctx.Request = httptest.NewRequest("POST", "/token", strings.NewReader(formData)) + s.ctx.Request.Header.Set("Content-Type", "application/x-www-form-urlencoded") +} + +func withURL(ctx *gin.Context, scheme, host string) { + ctx.Set("location", &url.URL{Scheme: scheme, Host: host}) +} diff --git a/database/application.go b/database/application.go index 3f001a8..4640774 100644 --- a/database/application.go +++ b/database/application.go @@ -43,6 +43,6 @@ func (d *GormDatabase) GetApplicationsByUser(userID uint) []*model.Application { } // UpdateApplication updates an application. -func (d *GormDatabase) UpdateApplication(app *model.Application) { - d.DB.Save(app) +func (d *GormDatabase) UpdateApplication(app *model.Application) error { + return d.DB.Save(app).Error } diff --git a/docs/package.go b/docs/package.go index f77ab9e..3a929d1 100644 --- a/docs/package.go +++ b/docs/package.go @@ -16,7 +16,7 @@ // // Schemes: http, https // Host: localhost -// Version: 1.0.5 +// Version: 1.0.6 // License: MIT https://github.com/gotify/server/blob/master/LICENSE // // Consumes: diff --git a/docs/spec.json b/docs/spec.json index 4c792be..dc310ed 100644 --- a/docs/spec.json +++ b/docs/spec.json @@ -17,7 +17,7 @@ "name": "MIT", "url": "https://github.com/gotify/server/blob/master/LICENSE" }, - "version": "1.0.5" + "version": "1.0.6" }, "host": "localhost", "paths": { @@ -41,7 +41,7 @@ "application/json" ], "tags": [ - "token" + "application" ], "summary": "Return all applications.", "operationId": "getApps", @@ -88,7 +88,7 @@ "application/json" ], "tags": [ - "token" + "application" ], "summary": "Create an application.", "operationId": "createApp", @@ -126,6 +126,74 @@ } }, "/application/{id}": { + "put": { + "security": [ + { + "clientTokenHeader": [] + }, + { + "clientTokenQuery": [] + }, + { + "basicAuth": [] + } + ], + "description": "Update info for an application", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "application" + ], + "operationId": "updateApplication", + "parameters": [ + { + "description": "the application to update", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/Application" + } + }, + { + "type": "integer", + "description": "the application id", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "Ok", + "schema": { + "$ref": "#/definitions/Application" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/Error" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/Error" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/Error" + } + } + } + }, "delete": { "security": [ { @@ -145,7 +213,7 @@ "application/json" ], "tags": [ - "token" + "application" ], "summary": "Delete an application.", "operationId": "deleteApp", @@ -198,7 +266,7 @@ "application/json" ], "tags": [ - "token" + "application" ], "operationId": "uploadAppImage", "parameters": [ @@ -374,7 +442,7 @@ "application/json" ], "tags": [ - "token" + "client" ], "summary": "Return all clients.", "operationId": "getClients", @@ -421,7 +489,7 @@ "application/json" ], "tags": [ - "token" + "client" ], "summary": "Create a client.", "operationId": "createClient", @@ -478,7 +546,7 @@ "application/json" ], "tags": [ - "token" + "client" ], "summary": "Delete a client.", "operationId": "deleteClient", diff --git a/router/router.go b/router/router.go index b4e0ee0..a77944d 100644 --- a/router/router.go +++ b/router/router.go @@ -26,8 +26,17 @@ func Create(db *database.GormDatabase, vInfo *model.VersionInfo, conf *config.Co streamHandler := stream.New(200*time.Second, 15*time.Second) authentication := auth.Auth{DB: db} messageHandler := api.MessageAPI{Notifier: streamHandler, DB: db} - tokenHandler := api.TokenAPI{DB: db, ImageDir: conf.UploadedImagesDir, NotifyDeleted: streamHandler.NotifyDeletedClient} + clientHandler := api.ClientAPI{ + DB: db, + ImageDir: conf.UploadedImagesDir, + NotifyDeleted: streamHandler.NotifyDeletedClient, + } + applicationHandler := api.ApplicationAPI{ + DB: db, + ImageDir: conf.UploadedImagesDir, + } userHandler := api.UserAPI{DB: db, PasswordStrength: conf.PassStrength, NotifyDeleted: streamHandler.NotifyDeletedUser} + g := gin.New() g.Use(gin.Logger(), gin.Recovery(), error.Handler(), location.Default()) @@ -103,7 +112,7 @@ func Create(db *database.GormDatabase, vInfo *model.VersionInfo, conf *config.Co clientAuth.Use(authentication.RequireClient()) app := clientAuth.Group("/application") { - // swagger:operation GET /application token getApps + // swagger:operation GET /application application getApps // // Return all applications. // @@ -131,9 +140,9 @@ func Create(db *database.GormDatabase, vInfo *model.VersionInfo, conf *config.Co // description: Forbidden // schema: // $ref: "#/definitions/Error" - app.GET("", tokenHandler.GetApplications) + app.GET("", applicationHandler.GetApplications) - // swagger:operation POST /application token createApp + // swagger:operation POST /application application createApp // // Create an application. // @@ -166,9 +175,9 @@ func Create(db *database.GormDatabase, vInfo *model.VersionInfo, conf *config.Co // description: Forbidden // schema: // $ref: "#/definitions/Error" - app.POST("", tokenHandler.CreateApplication) + app.POST("", applicationHandler.CreateApplication) - // swagger:operation POST /application/{id}/image token uploadAppImage + // swagger:operation POST /application/{id}/image application uploadAppImage // // Upload an image for an application // @@ -205,9 +214,53 @@ func Create(db *database.GormDatabase, vInfo *model.VersionInfo, conf *config.Co // description: Forbidden // schema: // $ref: "#/definitions/Error" - app.POST("/:id/image", tokenHandler.UploadApplicationImage) + app.POST("/:id/image", applicationHandler.UploadApplicationImage) - // swagger:operation DELETE /application/{id} token deleteApp + // swagger:operation PUT /application/{id} application updateApplication + // + // Update info for an application + // + // --- + // consumes: + // - application/json + // produces: + // - application/json + // security: + // - clientTokenHeader: [] + // - clientTokenQuery: [] + // - basicAuth: [] + // parameters: + // - name: body + // in: body + // description: the application to update + // required: true + // schema: + // $ref: "#/definitions/Application" + // - name: id + // in: path + // description: the application id + // required: true + // type: integer + // responses: + // 200: + // description: Ok + // schema: + // $ref: "#/definitions/Application" + // 400: + // description: Bad Request + // schema: + // $ref: "#/definitions/Error" + // 401: + // description: Unauthorized + // schema: + // $ref: "#/definitions/Error" + // 403: + // description: Forbidden + // schema: + // $ref: "#/definitions/Error" + app.PUT("/:id", applicationHandler.UpdateApplication) + + // swagger:operation DELETE /application/{id} application deleteApp // // Delete an application. // @@ -237,7 +290,7 @@ func Create(db *database.GormDatabase, vInfo *model.VersionInfo, conf *config.Co // description: Forbidden // schema: // $ref: "#/definitions/Error" - app.DELETE("/:id", tokenHandler.DeleteApplication) + app.DELETE("/:id", applicationHandler.DeleteApplication) tokenMessage := app.Group("/:id/message") { @@ -321,7 +374,7 @@ func Create(db *database.GormDatabase, vInfo *model.VersionInfo, conf *config.Co client := clientAuth.Group("/client") { - // swagger:operation GET /client token getClients + // swagger:operation GET /client client getClients // // Return all clients. // @@ -349,9 +402,9 @@ func Create(db *database.GormDatabase, vInfo *model.VersionInfo, conf *config.Co // description: Forbidden // schema: // $ref: "#/definitions/Error" - client.GET("", tokenHandler.GetClients) + client.GET("", clientHandler.GetClients) - // swagger:operation POST /client token createClient + // swagger:operation POST /client client createClient // // Create a client. // @@ -384,9 +437,9 @@ func Create(db *database.GormDatabase, vInfo *model.VersionInfo, conf *config.Co // description: Forbidden // schema: // $ref: "#/definitions/Error" - client.POST("", tokenHandler.CreateClient) + client.POST("", clientHandler.CreateClient) - // swagger:operation DELETE /client/{id} token deleteClient + // swagger:operation DELETE /client/{id} client deleteClient // // Delete a client. // @@ -416,7 +469,7 @@ func Create(db *database.GormDatabase, vInfo *model.VersionInfo, conf *config.Co // description: Forbidden // schema: // $ref: "#/definitions/Error" - client.DELETE("/:id", tokenHandler.DeleteClient) + client.DELETE("/:id", clientHandler.DeleteClient) } message := clientAuth.Group("/message")