From 8dfb5c7a69e6d18d5e68d822882e5c03b6c5d5d4 Mon Sep 17 00:00:00 2001 From: Jannis Mattheis Date: Sun, 28 Jan 2018 17:39:43 +0100 Subject: [PATCH] Add token api (app and client) --- api/mock/mock_tokendatabase.go | 130 +++++++++++++++ api/token.go | 99 +++++++++++ api/token_test.go | 294 +++++++++++++++++++++++++++++++++ auth/token.go | 30 ++++ auth/token_test.go | 14 ++ 5 files changed, 567 insertions(+) create mode 100644 api/mock/mock_tokendatabase.go create mode 100644 api/token.go create mode 100644 api/token_test.go create mode 100644 auth/token.go create mode 100644 auth/token_test.go diff --git a/api/mock/mock_tokendatabase.go b/api/mock/mock_tokendatabase.go new file mode 100644 index 0000000..d3568cb --- /dev/null +++ b/api/mock/mock_tokendatabase.go @@ -0,0 +1,130 @@ +// Code generated by mockery v1.0.0 +package mock + +import mock "github.com/stretchr/testify/mock" +import model "github.com/jmattheis/memo/model" + +// MockTokenDatabase is an autogenerated mock type for the TokenDatabase type +type MockTokenDatabase struct { + mock.Mock +} + +// CreateApplication provides a mock function with given fields: application +func (_m *MockTokenDatabase) CreateApplication(application *model.Application) error { + ret := _m.Called(application) + + var r0 error + if rf, ok := ret.Get(0).(func(*model.Application) error); ok { + r0 = rf(application) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// CreateClient provides a mock function with given fields: client +func (_m *MockTokenDatabase) CreateClient(client *model.Client) error { + ret := _m.Called(client) + + var r0 error + if rf, ok := ret.Get(0).(func(*model.Client) error); ok { + r0 = rf(client) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// DeleteApplicationByID provides a mock function with given fields: id +func (_m *MockTokenDatabase) DeleteApplicationByID(id string) error { + ret := _m.Called(id) + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(id) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// DeleteClientID provides a mock function with given fields: id +func (_m *MockTokenDatabase) DeleteClientID(id string) error { + ret := _m.Called(id) + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(id) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// GetApplicationByID provides a mock function with given fields: id +func (_m *MockTokenDatabase) GetApplicationByID(id string) *model.Application { + ret := _m.Called(id) + + var r0 *model.Application + if rf, ok := ret.Get(0).(func(string) *model.Application); ok { + r0 = rf(id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.Application) + } + } + + return r0 +} + +// GetApplicationsByUser provides a mock function with given fields: userID +func (_m *MockTokenDatabase) GetApplicationsByUser(userID uint) []*model.Application { + ret := _m.Called(userID) + + var r0 []*model.Application + if rf, ok := ret.Get(0).(func(uint) []*model.Application); ok { + r0 = rf(userID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.Application) + } + } + + return r0 +} + +// GetClientByID provides a mock function with given fields: id +func (_m *MockTokenDatabase) GetClientByID(id string) *model.Client { + ret := _m.Called(id) + + var r0 *model.Client + if rf, ok := ret.Get(0).(func(string) *model.Client); ok { + r0 = rf(id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.Client) + } + } + + return r0 +} + +// GetClientsByUser provides a mock function with given fields: userID +func (_m *MockTokenDatabase) GetClientsByUser(userID uint) []*model.Client { + ret := _m.Called(userID) + + var r0 []*model.Client + if rf, ok := ret.Get(0).(func(uint) []*model.Client); ok { + r0 = rf(userID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.Client) + } + } + + return r0 +} diff --git a/api/token.go b/api/token.go new file mode 100644 index 0000000..1f22df3 --- /dev/null +++ b/api/token.go @@ -0,0 +1,99 @@ +package api + +import ( + "fmt" + "github.com/gin-gonic/gin" + "github.com/jmattheis/memo/auth" + "github.com/jmattheis/memo/model" +) + +// The TokenDatabase interface for encapsulating database access. +type TokenDatabase interface { + CreateApplication(application *model.Application) error + GetApplicationByID(id string) *model.Application + GetApplicationsByUser(userID uint) []*model.Application + DeleteApplicationByID(id string) error + + CreateClient(client *model.Client) error + GetClientByID(id string) *model.Client + GetClientsByUser(userID uint) []*model.Client + DeleteClientID(id string) error +} + +// The TokenAPI provides handlers for managing clients and applications. +type TokenAPI struct { + DB TokenDatabase +} + +// CreateApplication creates an application and returns the access token. +func (a *TokenAPI) CreateApplication(ctx *gin.Context) { + app := model.Application{} + if err := ctx.Bind(&app); err == nil { + app.ID = generateNotExistingToken(auth.GenerateApplicationToken, a.applicationExists) + app.UserID = auth.GetUserID(ctx) + a.DB.CreateApplication(&app) + ctx.JSON(200, app) + } +} + +// 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.ID = 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) { + userID := auth.GetUserID(ctx) + apps := a.DB.GetApplicationsByUser(userID) + ctx.JSON(200, apps) +} + +// GetClients returns all clients a user has. +func (a *TokenAPI) GetClients(ctx *gin.Context) { + userID := auth.GetUserID(ctx) + apps := a.DB.GetClientsByUser(userID) + ctx.JSON(200, apps) +} + +// DeleteApplication deletes an application by its id. +func (a *TokenAPI) DeleteApplication(ctx *gin.Context) { + appID := ctx.Param("id") + if app := a.DB.GetApplicationByID(appID); app != nil && app.UserID == auth.GetUserID(ctx) { + a.DB.DeleteApplicationByID(appID) + } else { + ctx.AbortWithError(404, fmt.Errorf("app with id %s doesn't exists", appID)) + } +} + +// DeleteClient deletes a client by its id. +func (a *TokenAPI) DeleteClient(ctx *gin.Context) { + clientID := ctx.Param("id") + if client := a.DB.GetClientByID(clientID); client != nil && client.UserID == auth.GetUserID(ctx) { + a.DB.DeleteClientID(clientID) + } else { + ctx.AbortWithError(404, fmt.Errorf("client with id %s doesn't exists", clientID)) + } +} + +func (a *TokenAPI) applicationExists(appID string) bool { + return a.DB.GetApplicationByID(appID) != nil +} + +func (a *TokenAPI) clientExists(clientID string) bool { + return a.DB.GetClientByID(clientID) != 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/token_test.go new file mode 100644 index 0000000..456a073 --- /dev/null +++ b/api/token_test.go @@ -0,0 +1,294 @@ +package api + +import ( + "errors" + "github.com/gin-gonic/gin" + apimock "github.com/jmattheis/memo/api/mock" + "github.com/jmattheis/memo/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" + "io/ioutil" + "math/rand" + "net/http/httptest" + "strings" + "testing" +) + +var ( + firstApplicationToken = "APorrUa5b1IIK3y" + secondApplicationToken = "AKo_Pp6ww_9vZal" + firstClientToken = "CPorrUa5b1IIK3y" + secondClientToken = "CKo_Pp6ww_9vZal" +) + +func TestTokenSuite(t *testing.T) { + suite.Run(t, new(TokenSuite)) +} + +type TokenSuite struct { + suite.Suite + db *apimock.MockTokenDatabase + a *TokenAPI + ctx *gin.Context + recorder *httptest.ResponseRecorder +} + +func (s *TokenSuite) BeforeTest(suiteName, testName string) { + gin.SetMode(gin.TestMode) + rand.Seed(50) + s.recorder = httptest.NewRecorder() + s.ctx, _ = gin.CreateTestContext(s.recorder) + s.db = &apimock.MockTokenDatabase{} + s.a = &TokenAPI{DB: s.db} +} + +// test application api + +func (s *TokenSuite) Test_CreateApplication_mapAllParameters() { + expected := &model.Application{ID: firstApplicationToken, UserID: 5, Name: "custom_name", Description: "description_text"} + + s.ctx.Set("user", &model.User{ID: 5}) + s.withFormData("name=custom_name&description=description_text") + + s.db.On("GetApplicationByID", firstApplicationToken).Return(nil) + s.db.On("CreateApplication", expected).Return(nil) + + s.a.CreateApplication(s.ctx) + + s.db.AssertCalled(s.T(), "CreateApplication", expected) + assert.Equal(s.T(), 200, s.recorder.Code) +} + +func (s *TokenSuite) Test_CreateApplication_expectBadRequestOnEmptyName() { + s.ctx.Set("user", &model.User{ID: 5}) + s.withFormData("name=&description=description_text") + + s.a.CreateApplication(s.ctx) + + s.db.AssertNotCalled(s.T(), "CreateApplication", mock.Anything) + assert.Equal(s.T(), 400, s.recorder.Code) +} + +func (s *TokenSuite) Test_DeleteApplication_expectNotFoundOnCurrentUserIsNotOwner() { + s.ctx.Set("user", &model.User{ID: 2}) + s.ctx.Request = httptest.NewRequest("DELETE", "/token/"+firstApplicationToken, nil) + s.ctx.Params = gin.Params{{Key: "id", Value: firstApplicationToken}} + + s.db.On("GetApplicationByID", firstApplicationToken).Return(&model.Application{ID: firstApplicationToken, UserID: 5}) + + s.a.DeleteApplication(s.ctx) + + s.db.AssertNotCalled(s.T(), "DeleteApplicationByID", mock.Anything) + assert.Equal(s.T(), 404, s.recorder.Code) +} + +func (s *TokenSuite) Test_CreateApplication_onlyRequiredParameters() { + expected := &model.Application{ID: firstApplicationToken, Name: "custom_name", UserID: 5} + + s.ctx.Set("user", &model.User{ID: 5}) + s.withFormData("name=custom_name") + + s.db.On("GetApplicationByID", firstApplicationToken).Return(nil) + s.db.On("CreateApplication", expected).Return(nil) + + s.a.CreateApplication(s.ctx) + + s.db.AssertCalled(s.T(), "CreateApplication", expected) + assert.Equal(s.T(), 200, s.recorder.Code) +} + +func (s *TokenSuite) Test_CreateApplication_returnsApplicationWithID() { + expected := &model.Application{ID: firstApplicationToken, Name: "custom_name", UserID: 5} + + s.ctx.Set("user", &model.User{ID: 5}) + s.withFormData("name=custom_name") + + s.db.On("GetApplicationByID", firstApplicationToken).Return(nil) + s.db.On("CreateApplication", expected).Return(nil) + + s.a.CreateApplication(s.ctx) + + assert.Equal(s.T(), 200, s.recorder.Code) + bytes, _ := ioutil.ReadAll(s.recorder.Body) + + assert.Equal(s.T(), `{"ID":"APorrUa5b1IIK3y","name":"custom_name","description":""}`, string(bytes)) +} + +func (s *TokenSuite) Test_CreateApplication_withExistingToken() { + expected := &model.Application{ID: secondApplicationToken, Name: "custom_name", UserID: 5} + + s.ctx.Set("user", &model.User{ID: 5}) + s.withFormData("name=custom_name") + + s.db.On("GetApplicationByID", firstApplicationToken).Return(&model.Application{ID: firstApplicationToken}) + s.db.On("GetApplicationByID", secondApplicationToken).Return(nil) + s.db.On("CreateApplication", expected).Return(nil) + + s.a.CreateApplication(s.ctx) + + s.db.AssertCalled(s.T(), "CreateApplication", expected) + assert.Equal(s.T(), 200, s.recorder.Code) +} + +func (s *TokenSuite) Test_GetApplications() { + s.ctx.Set("user", &model.User{ID: 5}) + s.ctx.Request = httptest.NewRequest("GET", "/tokens", nil) + + s.db.On("GetApplicationsByUser", uint(5)).Return([]*model.Application{ + {ID: "perfper", Name: "first", Description: "desc"}, + {ID: "asdasd", Name: "second", Description: "desc2"}, + }) + s.a.GetApplications(s.ctx) + + assert.Equal(s.T(), 200, s.recorder.Code) + bytes, _ := ioutil.ReadAll(s.recorder.Body) + + assert.Equal(s.T(), `[{"ID":"perfper","name":"first","description":"desc"},{"ID":"asdasd","name":"second","description":"desc2"}]`, string(bytes)) +} + +func (s *TokenSuite) Test_DeleteApplication_expectNotFound() { + s.ctx.Set("user", &model.User{ID: 5}) + s.ctx.Request = httptest.NewRequest("DELETE", "/token/"+firstApplicationToken, nil) + s.ctx.Params = gin.Params{{Key: "id", Value: firstApplicationToken}} + + s.db.On("DeleteApplicationByID", firstApplicationToken).Return(errors.New("what? that does not exist")) + s.db.On("GetApplicationByID", firstApplicationToken).Return(nil) + + s.a.DeleteApplication(s.ctx) + + assert.Equal(s.T(), 404, s.recorder.Code) +} + +func (s *TokenSuite) Test_DeleteApplication() { + s.ctx.Set("user", &model.User{ID: 5}) + s.ctx.Request = httptest.NewRequest("DELETE", "/token/"+firstApplicationToken, nil) + s.ctx.Params = gin.Params{{Key: "id", Value: firstApplicationToken}} + + s.db.On("DeleteApplicationByID", firstApplicationToken).Return(nil) + s.db.On("GetApplicationByID", firstApplicationToken).Return(&model.Application{ID: firstApplicationToken, Name: "custom_name", UserID: 5}) + + s.a.DeleteApplication(s.ctx) + + assert.Equal(s.T(), 200, s.recorder.Code) +} + +// test client api + +func (s *TokenSuite) Test_CreateClient_mapAllParameters() { + expected := &model.Client{ID: firstClientToken, UserID: 5, Name: "custom_name"} + + s.ctx.Set("user", &model.User{ID: 5}) + s.withFormData("name=custom_name&description=description_text") + + s.db.On("GetClientByID", firstClientToken).Return(nil) + s.db.On("CreateClient", expected).Return(nil) + + s.a.CreateClient(s.ctx) + + s.db.AssertCalled(s.T(), "CreateClient", expected) + assert.Equal(s.T(), 200, s.recorder.Code) +} + +func (s *TokenSuite) Test_CreateClient_expectBadRequestOnEmptyName() { + s.ctx.Set("user", &model.User{ID: 5}) + s.withFormData("name=&description=description_text") + + s.a.CreateClient(s.ctx) + + s.db.AssertNotCalled(s.T(), "CreateClient", mock.Anything) + assert.Equal(s.T(), 400, s.recorder.Code) +} + +func (s *TokenSuite) Test_DeleteClient_expectNotFoundOnCurrentUserIsNotOwner() { + s.ctx.Set("user", &model.User{ID: 2}) + s.ctx.Request = httptest.NewRequest("DELETE", "/token/"+firstClientToken, nil) + s.ctx.Params = gin.Params{{Key: "id", Value: firstClientToken}} + + s.db.On("GetClientByID", firstClientToken).Return(&model.Client{ID: firstClientToken, UserID: 5}) + + s.a.DeleteClient(s.ctx) + + s.db.AssertNotCalled(s.T(), "DeleteClientID", mock.Anything) + assert.Equal(s.T(), 404, s.recorder.Code) +} + +func (s *TokenSuite) Test_CreateClient_returnsClientWithID() { + expected := &model.Client{ID: firstClientToken, Name: "custom_name", UserID: 5} + + s.ctx.Set("user", &model.User{ID: 5}) + s.withFormData("name=custom_name") + + s.db.On("GetClientByID", firstClientToken).Return(nil) + s.db.On("CreateClient", expected).Return(nil) + + s.a.CreateClient(s.ctx) + + assert.Equal(s.T(), 200, s.recorder.Code) + bytes, _ := ioutil.ReadAll(s.recorder.Body) + + assert.Equal(s.T(), `{"ID":"CPorrUa5b1IIK3y","name":"custom_name"}`, string(bytes)) +} + +func (s *TokenSuite) Test_CreateClient_withExistingToken() { + expected := &model.Client{ID: secondClientToken, Name: "custom_name", UserID: 5} + + s.ctx.Set("user", &model.User{ID: 5}) + s.withFormData("name=custom_name") + + s.db.On("GetClientByID", firstClientToken).Return(&model.Client{ID: firstClientToken}) + s.db.On("GetClientByID", secondClientToken).Return(nil) + s.db.On("CreateClient", expected).Return(nil) + + s.a.CreateClient(s.ctx) + + s.db.AssertCalled(s.T(), "CreateClient", expected) + assert.Equal(s.T(), 200, s.recorder.Code) +} + +func (s *TokenSuite) Test_GetClients() { + s.ctx.Set("user", &model.User{ID: 5}) + s.ctx.Request = httptest.NewRequest("GET", "/tokens", nil) + + s.db.On("GetClientsByUser", uint(5)).Return([]*model.Client{ + {ID: "perfper", Name: "first"}, + {ID: "asdasd", Name: "second"}, + }) + s.a.GetClients(s.ctx) + + assert.Equal(s.T(), 200, s.recorder.Code) + bytes, _ := ioutil.ReadAll(s.recorder.Body) + + assert.Equal(s.T(), `[{"ID":"perfper","name":"first"},{"ID":"asdasd","name":"second"}]`, string(bytes)) +} + +func (s *TokenSuite) Test_DeleteClient_expectNotFound() { + s.ctx.Set("user", &model.User{ID: 5}) + s.ctx.Request = httptest.NewRequest("DELETE", "/token/"+firstClientToken, nil) + s.ctx.Params = gin.Params{{Key: "id", Value: firstClientToken}} + + s.db.On("DeleteClientID", firstClientToken).Return(errors.New("what? that does not exist")) + s.db.On("GetClientByID", firstClientToken).Return(nil) + + s.a.DeleteClient(s.ctx) + + assert.Equal(s.T(), 404, s.recorder.Code) +} + +func (s *TokenSuite) Test_DeleteClient() { + s.ctx.Set("user", &model.User{ID: 5}) + s.ctx.Request = httptest.NewRequest("DELETE", "/token/"+firstClientToken, nil) + s.ctx.Params = gin.Params{{Key: "id", Value: firstClientToken}} + + s.db.On("DeleteClientID", firstClientToken).Return(nil) + s.db.On("GetClientByID", firstClientToken).Return(&model.Client{ID: firstClientToken, Name: "custom_name", UserID: 5}) + + s.a.DeleteClient(s.ctx) + + assert.Equal(s.T(), 200, 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") +} diff --git a/auth/token.go b/auth/token.go new file mode 100644 index 0000000..eea1761 --- /dev/null +++ b/auth/token.go @@ -0,0 +1,30 @@ +package auth + +import ( + "math/rand" +) + +var ( + tokenCharacters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-_") + randomTokenLength = 14 + applicationPrefix = "A" + clientPrefix = "C" +) + +// GenerateApplicationToken generates an application token. +func GenerateApplicationToken() string { + return generateRandomToken(applicationPrefix) +} + +// GenerateClientToken generates a client token. +func GenerateClientToken() string { + return generateRandomToken(clientPrefix) +} + +func generateRandomToken(prefix string) string { + b := make([]rune, randomTokenLength) + for i := range b { + b[i] = tokenCharacters[rand.Intn(len(tokenCharacters))] + } + return prefix + string(b) +} diff --git a/auth/token_test.go b/auth/token_test.go new file mode 100644 index 0000000..31ac89e --- /dev/null +++ b/auth/token_test.go @@ -0,0 +1,14 @@ +package auth + +import ( + "github.com/stretchr/testify/assert" + "strings" + "testing" +) + +func TestTokenHavePrefix(t *testing.T) { + for i := 0; i < 50; i++ { + assert.True(t, strings.HasPrefix(GenerateApplicationToken(), "A")) + assert.True(t, strings.HasPrefix(GenerateClientToken(), "C")) + } +}