From fb946b5d53124fc6ea534e2b24a0560cd9b5fbf1 Mon Sep 17 00:00:00 2001 From: prosenjitjoy Date: Sat, 7 Oct 2023 03:24:36 +0600 Subject: [PATCH 1/2] added middleware and authorizatoin --- .github/workflows/ci.yml | 2 +- api/account.go | 15 ++++- api/account_test.go | 117 +++++++++++++++++++++++++++++++++--- api/middleware.go | 52 ++++++++++++++++ api/middleware_test.go | 104 ++++++++++++++++++++++++++++++++ api/server.go | 15 +++-- api/transfer.go | 25 +++++--- api/transfer_test.go | 9 ++- database/db/account.sql.go | 12 ++-- database/db/account_test.go | 9 ++- database/query/account.sql | 5 +- diagram.png | Bin 40417 -> 0 bytes 12 files changed, 329 insertions(+), 36 deletions(-) create mode 100644 api/middleware.go create mode 100644 api/middleware_test.go delete mode 100644 diagram.png diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e99b9eb..800ec98 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v4 with: - go-version: '1.20' + go-version: '^1.20' - name: Install golang-migrate run: | diff --git a/api/account.go b/api/account.go index 4a06db2..b30884a 100644 --- a/api/account.go +++ b/api/account.go @@ -1,8 +1,10 @@ package api import ( + "errors" "fmt" "main/database/db" + "main/token" "net/http" "github.com/gin-gonic/gin" @@ -10,7 +12,6 @@ import ( ) type createAccountRequest struct { - Owner string `json:"owner" binding:"required"` Currency string `json:"currency" binding:"required,currency"` } @@ -21,8 +22,10 @@ func (s *Server) createAccount(ctx *gin.Context) { return } + authPayload := ctx.MustGet(authorizationPayloadKey).(*token.Payload) + arg := db.CreateAccountParams{ - Owner: req.Owner, + Owner: authPayload.Username, Balance: 0, Currency: req.Currency, } @@ -62,6 +65,12 @@ func (s *Server) getAcount(ctx *gin.Context) { return } + authPayload := ctx.MustGet(authorizationPayloadKey).(*token.Payload) + if account.Owner != authPayload.Username { + err := errors.New("account doesn't belong to the authenticated user") + ctx.JSON(http.StatusUnauthorized, errorResponse(err)) + return + } ctx.JSON(http.StatusOK, account) } @@ -77,7 +86,9 @@ func (s *Server) listAcount(ctx *gin.Context) { return } + authPayload := ctx.MustGet(authorizationPayloadKey).(*token.Payload) arg := &db.ListAccountsParams{ + Owner: authPayload.Username, Limit: req.PageSize, Offset: (req.PageID - 1) * req.PageSize, } diff --git a/api/account_test.go b/api/account_test.go index 2237983..c525adf 100644 --- a/api/account_test.go +++ b/api/account_test.go @@ -3,11 +3,13 @@ package api import ( "bytes" "encoding/json" + "time" "fmt" "io" "main/database/db" "main/database/mockdb" + "main/token" "main/util" "net/http" "net/http/httptest" @@ -20,17 +22,22 @@ import ( ) func TestGetAccountAPI(t *testing.T) { - account := randomAccount() + user, _ := randomUser(t) + account := randomAccount(user.Username) testCases := []struct { name string accountID int64 + setupAuth func(t *testing.T, request *http.Request, tokenMaker token.Maker) buildStubs func(store *mockdb.MockStore) checkResponse func(t *testing.T, recorder *httptest.ResponseRecorder) }{ { name: "OK", accountID: account.ID, + setupAuth: func(t *testing.T, request *http.Request, tokenMaker token.Maker) { + addAuthorization(t, request, tokenMaker, authorizationTypeBearer, user.Username, time.Minute) + }, buildStubs: func(store *mockdb.MockStore) { store.EXPECT().GetAccount(gomock.Any(), gomock.Eq(account.ID)).Times(1).Return(account, nil) }, @@ -42,6 +49,9 @@ func TestGetAccountAPI(t *testing.T) { { name: "NotFound", accountID: account.ID, + setupAuth: func(t *testing.T, request *http.Request, tokenMaker token.Maker) { + addAuthorization(t, request, tokenMaker, authorizationTypeBearer, user.Username, time.Minute) + }, buildStubs: func(store *mockdb.MockStore) { store.EXPECT().GetAccount(gomock.Any(), gomock.Eq(account.ID)).Times(1).Return(&db.Account{}, pgx.ErrNoRows) }, @@ -52,6 +62,9 @@ func TestGetAccountAPI(t *testing.T) { { name: "InternalError", accountID: account.ID, + setupAuth: func(t *testing.T, request *http.Request, tokenMaker token.Maker) { + addAuthorization(t, request, tokenMaker, authorizationTypeBearer, user.Username, time.Minute) + }, buildStubs: func(store *mockdb.MockStore) { store.EXPECT().GetAccount(gomock.Any(), gomock.Eq(account.ID)).Times(1).Return(&db.Account{}, pgx.ErrTxClosed) }, @@ -62,6 +75,9 @@ func TestGetAccountAPI(t *testing.T) { { name: "InvalidID", accountID: 0, + setupAuth: func(t *testing.T, request *http.Request, tokenMaker token.Maker) { + addAuthorization(t, request, tokenMaker, authorizationTypeBearer, user.Username, time.Minute) + }, buildStubs: func(store *mockdb.MockStore) { store.EXPECT().GetAccount(gomock.Any(), gomock.Any()).Times(0) }, @@ -69,6 +85,31 @@ func TestGetAccountAPI(t *testing.T) { require.Equal(t, http.StatusBadRequest, recorder.Code) }, }, + { + name: "UnauthorizedUser", + accountID: account.ID, + setupAuth: func(t *testing.T, request *http.Request, tokenMaker token.Maker) { + addAuthorization(t, request, tokenMaker, authorizationTypeBearer, "unauthorized_user", time.Minute) + }, + buildStubs: func(store *mockdb.MockStore) { + store.EXPECT().GetAccount(gomock.Any(), gomock.Eq(account.ID)).Times(1).Return(account, nil) + }, + checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) { + require.Equal(t, http.StatusUnauthorized, recorder.Code) + }, + }, + { + name: "NoAuthorization", + accountID: account.ID, + setupAuth: func(t *testing.T, request *http.Request, tokenMaker token.Maker) { + }, + buildStubs: func(store *mockdb.MockStore) { + store.EXPECT().GetAccount(gomock.Any(), gomock.Any()).Times(0) + }, + checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) { + require.Equal(t, http.StatusUnauthorized, recorder.Code) + }, + }, } for _, tc := range testCases { @@ -89,6 +130,7 @@ func TestGetAccountAPI(t *testing.T) { request, err := http.NewRequest(http.MethodGet, url, nil) require.NoError(t, err) + tc.setupAuth(t, request, server.tokenMaker) server.router.ServeHTTP(recorder, request) // check response @@ -97,10 +139,10 @@ func TestGetAccountAPI(t *testing.T) { } } -func randomAccount() *db.Account { +func randomAccount(owner string) *db.Account { return &db.Account{ ID: util.RandomInt(1, 1000), - Owner: util.RandomOwner(), + Owner: owner, Balance: util.RandomMoney(), Currency: util.RandomCurrency(), } @@ -117,11 +159,13 @@ func requireBodyMatchAccount(t *testing.T, body *bytes.Buffer, account db.Accoun } func TestCreateAccountAPI(t *testing.T) { - account := randomAccount() + user, _ := randomUser(t) + account := randomAccount(user.Username) testCases := []struct { name string body gin.H + setupAuth func(t *testing.T, request *http.Request, tokenMaker token.Maker) buildStubs func(store *mockdb.MockStore) checkResponse func(recoder *httptest.ResponseRecorder) }{ @@ -131,6 +175,9 @@ func TestCreateAccountAPI(t *testing.T) { "owner": account.Owner, "currency": account.Currency, }, + setupAuth: func(t *testing.T, request *http.Request, tokenMaker token.Maker) { + addAuthorization(t, request, tokenMaker, authorizationTypeBearer, user.Username, time.Minute) + }, buildStubs: func(store *mockdb.MockStore) { arg := &db.CreateAccountParams{ Owner: account.Owner, @@ -154,6 +201,9 @@ func TestCreateAccountAPI(t *testing.T) { "owner": account.Owner, "currency": account.Currency, }, + setupAuth: func(t *testing.T, request *http.Request, tokenMaker token.Maker) { + addAuthorization(t, request, tokenMaker, authorizationTypeBearer, user.Username, time.Minute) + }, buildStubs: func(store *mockdb.MockStore) { store.EXPECT(). CreateAccount(gomock.Any(), gomock.Any()). @@ -170,6 +220,9 @@ func TestCreateAccountAPI(t *testing.T) { "owner": account.Owner, "currency": "invalid", }, + setupAuth: func(t *testing.T, request *http.Request, tokenMaker token.Maker) { + addAuthorization(t, request, tokenMaker, authorizationTypeBearer, user.Username, time.Minute) + }, buildStubs: func(store *mockdb.MockStore) { store.EXPECT(). CreateAccount(gomock.Any(), gomock.Any()). @@ -200,6 +253,7 @@ func TestCreateAccountAPI(t *testing.T) { request, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(data)) require.NoError(t, err) + tc.setupAuth(t, request, server.tokenMaker) server.router.ServeHTTP(recorder, request) tc.checkResponse(recorder) }) @@ -207,10 +261,12 @@ func TestCreateAccountAPI(t *testing.T) { } func TestListAccountAPI(t *testing.T) { + user, _ := randomUser(t) + n := 5 accounts := make([]*db.Account, n) for i := 0; i < n; i++ { - accounts[i] = randomAccount() + accounts[i] = randomAccount(user.Username) } type Query struct { @@ -221,6 +277,7 @@ func TestListAccountAPI(t *testing.T) { testCases := []struct { name string query Query + setupAuth func(t *testing.T, request *http.Request, tokenMaker token.Maker) buildStubs func(store *mockdb.MockStore) checkResponse func(recorder *httptest.ResponseRecorder) }{ @@ -230,6 +287,9 @@ func TestListAccountAPI(t *testing.T) { pageID: 1, pageSize: n, }, + setupAuth: func(t *testing.T, request *http.Request, tokenMaker token.Maker) { + addAuthorization(t, request, tokenMaker, authorizationTypeBearer, user.Username, time.Minute) + }, buildStubs: func(store *mockdb.MockStore) { arg := &db.ListAccountsParams{ Limit: int32(n), @@ -249,6 +309,9 @@ func TestListAccountAPI(t *testing.T) { pageID: 1, pageSize: n, }, + setupAuth: func(t *testing.T, request *http.Request, tokenMaker token.Maker) { + addAuthorization(t, request, tokenMaker, authorizationTypeBearer, user.Username, time.Minute) + }, buildStubs: func(store *mockdb.MockStore) { store.EXPECT().ListAccounts(gomock.Any(), gomock.Any()).Times(1).Return([]*db.Account{}, pgx.ErrTxClosed) }, @@ -262,6 +325,9 @@ func TestListAccountAPI(t *testing.T) { pageID: -1, pageSize: n, }, + setupAuth: func(t *testing.T, request *http.Request, tokenMaker token.Maker) { + addAuthorization(t, request, tokenMaker, authorizationTypeBearer, user.Username, time.Minute) + }, buildStubs: func(store *mockdb.MockStore) { store.EXPECT().ListAccounts(gomock.Any(), gomock.Any()).Times(0) }, @@ -275,6 +341,9 @@ func TestListAccountAPI(t *testing.T) { pageID: 1, pageSize: 100000, }, + setupAuth: func(t *testing.T, request *http.Request, tokenMaker token.Maker) { + addAuthorization(t, request, tokenMaker, authorizationTypeBearer, user.Username, time.Minute) + }, buildStubs: func(store *mockdb.MockStore) { store.EXPECT().ListAccounts(gomock.Any(), gomock.Any()).Times(0) }, @@ -305,6 +374,7 @@ func TestListAccountAPI(t *testing.T) { q.Add("page_size", fmt.Sprintf("%d", tc.query.pageSize)) request.URL.RawQuery = q.Encode() + tc.setupAuth(t, request, server.tokenMaker) server.router.ServeHTTP(recorder, request) tc.checkResponse(recorder) }) @@ -322,12 +392,14 @@ func requireBodyMatchAccounts(t *testing.T, body *bytes.Buffer, accounts []*db.A } func TestUpdateAccountAPI(t *testing.T) { - account := randomAccount() + user, _ := randomUser(t) + account := randomAccount(user.Username) testCases := []struct { name string accountID int64 body gin.H + setupAuth func(t *testing.T, request *http.Request, tokenMaker token.Maker) buildStubs func(store *mockdb.MockStore) checkResponse func(recoder *httptest.ResponseRecorder) }{ @@ -337,6 +409,9 @@ func TestUpdateAccountAPI(t *testing.T) { body: gin.H{ "balance": account.Balance, }, + setupAuth: func(t *testing.T, request *http.Request, tokenMaker token.Maker) { + addAuthorization(t, request, tokenMaker, authorizationTypeBearer, user.Username, time.Minute) + }, buildStubs: func(store *mockdb.MockStore) { arg := &db.UpdateAccountParams{ ID: account.ID, @@ -359,6 +434,9 @@ func TestUpdateAccountAPI(t *testing.T) { body: gin.H{ "balance": account.Balance, }, + setupAuth: func(t *testing.T, request *http.Request, tokenMaker token.Maker) { + addAuthorization(t, request, tokenMaker, authorizationTypeBearer, user.Username, time.Minute) + }, buildStubs: func(store *mockdb.MockStore) { store.EXPECT(). UpdateAccount(gomock.Any(), gomock.Any()). @@ -375,6 +453,9 @@ func TestUpdateAccountAPI(t *testing.T) { body: gin.H{ "balance": account.Balance, }, + setupAuth: func(t *testing.T, request *http.Request, tokenMaker token.Maker) { + addAuthorization(t, request, tokenMaker, authorizationTypeBearer, user.Username, time.Minute) + }, buildStubs: func(store *mockdb.MockStore) { arg := &db.UpdateAccountParams{ ID: account.ID, @@ -393,6 +474,9 @@ func TestUpdateAccountAPI(t *testing.T) { body: gin.H{ "balance": account.Balance, }, + setupAuth: func(t *testing.T, request *http.Request, tokenMaker token.Maker) { + addAuthorization(t, request, tokenMaker, authorizationTypeBearer, user.Username, time.Minute) + }, buildStubs: func(store *mockdb.MockStore) { store.EXPECT().UpdateAccount(gomock.Any(), gomock.Any()).Times(0) }, @@ -406,6 +490,9 @@ func TestUpdateAccountAPI(t *testing.T) { body: gin.H{ "invalid": "invalid", }, + setupAuth: func(t *testing.T, request *http.Request, tokenMaker token.Maker) { + addAuthorization(t, request, tokenMaker, authorizationTypeBearer, user.Username, time.Minute) + }, buildStubs: func(store *mockdb.MockStore) { store.EXPECT(). UpdateAccount(gomock.Any(), gomock.Any()). @@ -436,6 +523,7 @@ func TestUpdateAccountAPI(t *testing.T) { request, err := http.NewRequest(http.MethodPatch, url, bytes.NewReader(data)) require.NoError(t, err) + tc.setupAuth(t, request, server.tokenMaker) server.router.ServeHTTP(recorder, request) tc.checkResponse(recorder) }) @@ -443,17 +531,22 @@ func TestUpdateAccountAPI(t *testing.T) { } func TestDeleteAccountAPI(t *testing.T) { - account := randomAccount() + user, _ := randomUser(t) + account := randomAccount(user.Username) testCases := []struct { name string accountID int64 + setupAuth func(t *testing.T, request *http.Request, tokenMaker token.Maker) buildStubs func(store *mockdb.MockStore) checkResponse func(t *testing.T, recorder *httptest.ResponseRecorder) }{ { name: "OK", accountID: account.ID, + setupAuth: func(t *testing.T, request *http.Request, tokenMaker token.Maker) { + addAuthorization(t, request, tokenMaker, authorizationTypeBearer, user.Username, time.Minute) + }, buildStubs: func(store *mockdb.MockStore) { store.EXPECT().DeleteAccount(gomock.Any(), gomock.Eq(account.ID)).Times(1) }, @@ -464,6 +557,9 @@ func TestDeleteAccountAPI(t *testing.T) { { name: "NotFound", accountID: account.ID, + setupAuth: func(t *testing.T, request *http.Request, tokenMaker token.Maker) { + addAuthorization(t, request, tokenMaker, authorizationTypeBearer, user.Username, time.Minute) + }, buildStubs: func(store *mockdb.MockStore) { store.EXPECT().DeleteAccount(gomock.Any(), gomock.Eq(account.ID)).Times(1).Return(pgx.ErrNoRows) }, @@ -474,6 +570,9 @@ func TestDeleteAccountAPI(t *testing.T) { { name: "InternalError", accountID: account.ID, + setupAuth: func(t *testing.T, request *http.Request, tokenMaker token.Maker) { + addAuthorization(t, request, tokenMaker, authorizationTypeBearer, user.Username, time.Minute) + }, buildStubs: func(store *mockdb.MockStore) { store.EXPECT().DeleteAccount(gomock.Any(), gomock.Eq(account.ID)).Times(1).Return(pgx.ErrTxClosed) }, @@ -484,6 +583,9 @@ func TestDeleteAccountAPI(t *testing.T) { { name: "InvalidID", accountID: 0, + setupAuth: func(t *testing.T, request *http.Request, tokenMaker token.Maker) { + addAuthorization(t, request, tokenMaker, authorizationTypeBearer, user.Username, time.Minute) + }, buildStubs: func(store *mockdb.MockStore) { store.EXPECT().DeleteAccount(gomock.Any(), gomock.Any()).Times(0) }, @@ -511,6 +613,7 @@ func TestDeleteAccountAPI(t *testing.T) { request, err := http.NewRequest(http.MethodDelete, url, nil) require.NoError(t, err) + tc.setupAuth(t, request, server.tokenMaker) server.router.ServeHTTP(recorder, request) // check response diff --git a/api/middleware.go b/api/middleware.go new file mode 100644 index 0000000..12e0343 --- /dev/null +++ b/api/middleware.go @@ -0,0 +1,52 @@ +package api + +import ( + "errors" + "fmt" + "main/token" + "net/http" + "strings" + + "github.com/gin-gonic/gin" +) + +const ( + authorizationHeaderKey = "authorization" + authorizationTypeBearer = "bearer" + authorizationPayloadKey = "auth_payload" +) + +func authMiddleware(tokenMaker token.Maker) gin.HandlerFunc { + return func(ctx *gin.Context) { + authorizationHeader := ctx.GetHeader(authorizationHeaderKey) + if len(authorizationHeader) == 0 { + err := errors.New("authorization header is not provided") + ctx.AbortWithStatusJSON(http.StatusUnauthorized, errorResponse(err)) + return + } + + fields := strings.Fields(authorizationHeader) + if len(fields) < 2 { + err := errors.New("invalid authorization header format") + ctx.AbortWithStatusJSON(http.StatusUnauthorized, errorResponse(err)) + return + } + + authorizationType := strings.ToLower(fields[0]) + if authorizationType != authorizationTypeBearer { + err := fmt.Errorf("unsupported authorization type %s", authorizationType) + ctx.AbortWithStatusJSON(http.StatusUnauthorized, errorResponse(err)) + return + } + + accessToken := fields[1] + payload, err := tokenMaker.VerifyToken(accessToken) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusUnauthorized, errorResponse(err)) + return + } + + ctx.Set(authorizationPayloadKey, payload) + ctx.Next() + } +} diff --git a/api/middleware_test.go b/api/middleware_test.go new file mode 100644 index 0000000..b3bd063 --- /dev/null +++ b/api/middleware_test.go @@ -0,0 +1,104 @@ +package api + +import ( + "fmt" + "main/token" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func addAuthorization( + t *testing.T, + request *http.Request, + tokenMaker token.Maker, + authorizationType string, + username string, + duration time.Duration, +) { + token, err := tokenMaker.CreateToken(username, duration) + require.NoError(t, err) + + authorizationHeader := fmt.Sprintf("%s %s", authorizationType, token) + request.Header.Set(authorizationHeaderKey, authorizationHeader) +} + +func TestAuthMiddleware(t *testing.T) { + testCases := []struct { + name string + setupAuth func(t *testing.T, request *http.Request, tokenMaker token.Maker) + checkResponse func(t *testing.T, recorder *httptest.ResponseRecorder) + }{ + { + name: "OK", + setupAuth: func(t *testing.T, request *http.Request, tokenMaker token.Maker) { + addAuthorization(t, request, tokenMaker, authorizationTypeBearer, "user", time.Minute) + }, + checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) { + require.Equal(t, http.StatusOK, recorder.Code) + }, + }, + { + name: "NoAuthorization", + setupAuth: func(t *testing.T, request *http.Request, tokenMaker token.Maker) { + }, + checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) { + require.Equal(t, http.StatusUnauthorized, recorder.Code) + }, + }, + { + name: "UnsupportedAuthorization", + setupAuth: func(t *testing.T, request *http.Request, tokenMaker token.Maker) { + addAuthorization(t, request, tokenMaker, "unsupported", "user", time.Minute) + }, + checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) { + require.Equal(t, http.StatusUnauthorized, recorder.Code) + }, + }, + { + name: "InvalidAuthorization", + setupAuth: func(t *testing.T, request *http.Request, tokenMaker token.Maker) { + addAuthorization(t, request, tokenMaker, "", "user", time.Minute) + }, + checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) { + require.Equal(t, http.StatusUnauthorized, recorder.Code) + }, + }, + { + name: "ExpiredToken", + setupAuth: func(t *testing.T, request *http.Request, tokenMaker token.Maker) { + addAuthorization(t, request, tokenMaker, authorizationTypeBearer, "user", -time.Minute) + }, + checkResponse: func(t *testing.T, recorder *httptest.ResponseRecorder) { + require.Equal(t, http.StatusUnauthorized, recorder.Code) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + server := newTestServer(t, nil) + + authPath := "/auth" + server.router.GET( + authPath, + authMiddleware(server.tokenMaker), + func(ctx *gin.Context) { + ctx.JSON(http.StatusOK, gin.H{}) + }, + ) + + recorder := httptest.NewRecorder() + request, err := http.NewRequest(http.MethodGet, authPath, nil) + require.NoError(t, err) + + tc.setupAuth(t, request, server.tokenMaker) + server.router.ServeHTTP(recorder, request) + tc.checkResponse(t, recorder) + }) + } +} diff --git a/api/server.go b/api/server.go index 587a330..99bfe3d 100644 --- a/api/server.go +++ b/api/server.go @@ -46,13 +46,16 @@ func (s *Server) setupRouter() { router.POST("/users", s.createUser) router.POST("/users/login", s.loginUser) - router.POST("/accounts", s.createAccount) - router.GET("/accounts/:id", s.getAcount) - router.GET("/accounts", s.listAcount) - router.PATCH("/accounts/:id", s.updateAccount) - router.DELETE("/accounts/:id", s.deleteAccount) + authRoutes := router.Group("/") + authRoutes.Use(authMiddleware(s.tokenMaker)) - router.POST("/transfers", s.createTransfer) + authRoutes.POST("/accounts", s.createAccount) + authRoutes.GET("/accounts/:id", s.getAcount) + authRoutes.GET("/accounts", s.listAcount) + authRoutes.PATCH("/accounts/:id", s.updateAccount) + authRoutes.DELETE("/accounts/:id", s.deleteAccount) + + authRoutes.POST("/transfers", s.createTransfer) s.router = router } diff --git a/api/transfer.go b/api/transfer.go index 8e5ee6b..14a193c 100644 --- a/api/transfer.go +++ b/api/transfer.go @@ -1,8 +1,10 @@ package api import ( + "errors" "fmt" "main/database/db" + "main/token" "net/http" "github.com/gin-gonic/gin" @@ -23,11 +25,20 @@ func (s *Server) createTransfer(ctx *gin.Context) { return } - if !s.validAccount(ctx, req.FromAccountID, req.Currency) { + fromAccount, valid := s.validAccount(ctx, req.FromAccountID, req.Currency) + if !valid { return } - if !s.validAccount(ctx, req.ToAccountID, req.Currency) { + authPayload := ctx.MustGet(authorizationPayloadKey).(*token.Payload) + if fromAccount.Owner != authPayload.Username { + err := errors.New("from account doesn't belong to the authenticated user") + ctx.JSON(http.StatusUnauthorized, errorResponse(err)) + return + } + + _, valid = s.validAccount(ctx, req.ToAccountID, req.Currency) + if !valid { return } @@ -46,24 +57,24 @@ func (s *Server) createTransfer(ctx *gin.Context) { ctx.JSON(http.StatusOK, result) } -func (s *Server) validAccount(ctx *gin.Context, accountId int64, currency string) bool { +func (s *Server) validAccount(ctx *gin.Context, accountId int64, currency string) (*db.Account, bool) { account, err := s.store.GetAccount(ctx, accountId) if err != nil { if err == pgx.ErrNoRows { ctx.JSON(http.StatusNotFound, errorResponse(err)) - return false + return account, false } ctx.JSON(http.StatusInternalServerError, errorResponse(err)) - return false + return account, false } if account.Currency != currency { err := fmt.Errorf("account [%d] currency mismatch: %s vs %s", account.ID, account.Currency, currency) ctx.JSON(http.StatusBadRequest, errorResponse(err)) - return false + return account, false } - return true + return account, true } diff --git a/api/transfer_test.go b/api/transfer_test.go index 536e307..4b9d060 100644 --- a/api/transfer_test.go +++ b/api/transfer_test.go @@ -18,9 +18,12 @@ import ( func TestTransferAPI(t *testing.T) { amount := int64(10) - account1 := randomAccount() - account2 := randomAccount() - account3 := randomAccount() + user1, _ := randomUser(t) + user2, _ := randomUser(t) + user3, _ := randomUser(t) + account1 := randomAccount(user1.Username) + account2 := randomAccount(user2.Username) + account3 := randomAccount(user3.Username) account1.Currency = util.USD account2.Currency = util.USD diff --git a/database/db/account.sql.go b/database/db/account.sql.go index a0adce0..3bde7a6 100644 --- a/database/db/account.sql.go +++ b/database/db/account.sql.go @@ -111,18 +111,20 @@ func (q *Queries) GetAccountForUpdate(ctx context.Context, id int64) (*Account, const listAccounts = `-- name: ListAccounts :many SELECT id, owner, balance, currency, created_at FROM accounts +WHERE owner = $1 ORDER BY id -LIMIT $1 -OFFSET $2 +LIMIT $2 +OFFSET $3 ` type ListAccountsParams struct { - Limit int32 `db:"limit" json:"limit"` - Offset int32 `db:"offset" json:"offset"` + Owner string `db:"owner" json:"owner"` + Limit int32 `db:"limit" json:"limit"` + Offset int32 `db:"offset" json:"offset"` } func (q *Queries) ListAccounts(ctx context.Context, arg *ListAccountsParams) ([]*Account, error) { - rows, err := q.db.Query(ctx, listAccounts, arg.Limit, arg.Offset) + rows, err := q.db.Query(ctx, listAccounts, arg.Owner, arg.Limit, arg.Offset) if err != nil { return nil, err } diff --git a/database/db/account_test.go b/database/db/account_test.go index fa13c17..00329bc 100644 --- a/database/db/account_test.go +++ b/database/db/account_test.go @@ -83,20 +83,23 @@ func TestDeleteAccount(t *testing.T) { } func TestListAccounts(t *testing.T) { + var lastAccount Account for i := 0; i < 10; i++ { - createRandomAccount(t) + lastAccount = createRandomAccount(t) } arg := ListAccountsParams{ + Owner: lastAccount.Owner, Limit: 5, - Offset: 5, + Offset: 0, } accounts, err := testQueries.ListAccounts(context.Background(), &arg) require.NoError(t, err) - require.Len(t, accounts, 5) + require.NotEmpty(t, accounts) for _, account := range accounts { require.NotEmpty(t, account) + require.Equal(t, lastAccount.Owner, account.Owner) } } diff --git a/database/query/account.sql b/database/query/account.sql index 4d2831b..97520b2 100644 --- a/database/query/account.sql +++ b/database/query/account.sql @@ -17,9 +17,10 @@ FOR NO KEY UPDATE; -- name: ListAccounts :many SELECT * FROM accounts +WHERE owner = $1 ORDER BY id -LIMIT $1 -OFFSET $2; +LIMIT $2 +OFFSET $3; -- name: AddAccountBalance :one UPDATE accounts diff --git a/diagram.png b/diagram.png deleted file mode 100644 index 6af67d95bbd78a634d01255ce371105c3efda36b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 40417 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 Date: Sat, 7 Oct 2023 04:16:09 +0600 Subject: [PATCH 2/2] added middleware and authorizatoin --- api/account_test.go | 1 + api/transfer_test.go | 7 ++++ database/db/account_test.go | 12 +++--- database/db/entry.sql.go | 12 +++--- database/db/entry_test.go | 51 +++++++++++------------ database/db/main_test.go | 13 ++---- database/db/store_test.go | 22 ++++------ database/db/transfer.sql.go | 18 +++++--- database/db/transfer_test.go | 81 +++++++++++++++--------------------- database/db/user_test.go | 4 +- database/query/entry.sql | 5 ++- database/query/transfer.sql | 5 ++- 12 files changed, 113 insertions(+), 118 deletions(-) diff --git a/api/account_test.go b/api/account_test.go index c525adf..7e3dd0f 100644 --- a/api/account_test.go +++ b/api/account_test.go @@ -292,6 +292,7 @@ func TestListAccountAPI(t *testing.T) { }, buildStubs: func(store *mockdb.MockStore) { arg := &db.ListAccountsParams{ + Owner: user.Username, Limit: int32(n), Offset: 0, } diff --git a/api/transfer_test.go b/api/transfer_test.go index 4b9d060..01f4e23 100644 --- a/api/transfer_test.go +++ b/api/transfer_test.go @@ -5,10 +5,12 @@ import ( "encoding/json" "main/database/db" "main/database/mockdb" + "main/token" "main/util" "net/http" "net/http/httptest" "testing" + "time" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" @@ -32,6 +34,7 @@ func TestTransferAPI(t *testing.T) { testCases := []struct { name string body gin.H + setupAuth func(t *testing.T, request *http.Request, tokenMaker token.Maker) buildStubs func(store *mockdb.MockStore) checkResponse func(recorder *httptest.ResponseRecorder) }{ @@ -43,6 +46,9 @@ func TestTransferAPI(t *testing.T) { "amount": amount, "currency": util.USD, }, + setupAuth: func(t *testing.T, request *http.Request, tokenMaker token.Maker) { + addAuthorization(t, request, tokenMaker, authorizationTypeBearer, user1.Username, time.Minute) + }, buildStubs: func(store *mockdb.MockStore) { store.EXPECT().GetAccount(gomock.Any(), gomock.Eq(account1.ID)).Times(1).Return(account1, nil) store.EXPECT().GetAccount(gomock.Any(), gomock.Eq(account2.ID)).Times(1).Return(account2, nil) @@ -80,6 +86,7 @@ func TestTransferAPI(t *testing.T) { request, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(data)) require.NoError(t, err) + tc.setupAuth(t, request, server.tokenMaker) server.router.ServeHTTP(recorder, request) tc.checkResponse(recorder) }) diff --git a/database/db/account_test.go b/database/db/account_test.go index 00329bc..23fc248 100644 --- a/database/db/account_test.go +++ b/database/db/account_test.go @@ -19,7 +19,7 @@ func createRandomAccount(t *testing.T) Account { Currency: util.RandomCurrency(), } - account, err := testQueries.CreateAccount(context.Background(), &arg) + account, err := testStore.CreateAccount(context.Background(), &arg) require.NoError(t, err) require.NotEmpty(t, account) @@ -39,7 +39,7 @@ func TestCreateAccount(t *testing.T) { func TestGetAccount(t *testing.T) { account1 := createRandomAccount(t) - account2, err := testQueries.GetAccount(context.Background(), account1.ID) + account2, err := testStore.GetAccount(context.Background(), account1.ID) require.NoError(t, err) require.NotEmpty(t, account2) @@ -59,7 +59,7 @@ func TestUpdateAccount(t *testing.T) { Balance: util.RandomMoney(), } - account2, err := testQueries.UpdateAccount(context.Background(), &arg) + account2, err := testStore.UpdateAccount(context.Background(), &arg) require.NoError(t, err) require.NotEmpty(t, account2) @@ -73,10 +73,10 @@ func TestUpdateAccount(t *testing.T) { func TestDeleteAccount(t *testing.T) { account1 := createRandomAccount(t) - err := testQueries.DeleteAccount(context.Background(), account1.ID) + err := testStore.DeleteAccount(context.Background(), account1.ID) require.NoError(t, err) - account2, err := testQueries.GetAccount(context.Background(), account1.ID) + account2, err := testStore.GetAccount(context.Background(), account1.ID) require.Error(t, err) require.EqualError(t, err, pgx.ErrNoRows.Error()) require.Empty(t, account2) @@ -94,7 +94,7 @@ func TestListAccounts(t *testing.T) { Offset: 0, } - accounts, err := testQueries.ListAccounts(context.Background(), &arg) + accounts, err := testStore.ListAccounts(context.Background(), &arg) require.NoError(t, err) require.NotEmpty(t, accounts) diff --git a/database/db/entry.sql.go b/database/db/entry.sql.go index a233c3d..8fd7ec4 100644 --- a/database/db/entry.sql.go +++ b/database/db/entry.sql.go @@ -62,18 +62,20 @@ func (q *Queries) GetEntry(ctx context.Context, id int64) (*Entry, error) { const listEntries = `-- name: ListEntries :many SELECT id, account_id, amount, created_at FROM entries +WHERE account_id = $1 ORDER BY id -LIMIT $1 -OFFSET $2 +LIMIT $2 +OFFSET $3 ` type ListEntriesParams struct { - Limit int32 `db:"limit" json:"limit"` - Offset int32 `db:"offset" json:"offset"` + AccountID int64 `db:"account_id" json:"account_id"` + Limit int32 `db:"limit" json:"limit"` + Offset int32 `db:"offset" json:"offset"` } func (q *Queries) ListEntries(ctx context.Context, arg *ListEntriesParams) ([]*Entry, error) { - rows, err := q.db.Query(ctx, listEntries, arg.Limit, arg.Offset) + rows, err := q.db.Query(ctx, listEntries, arg.AccountID, arg.Limit, arg.Offset) if err != nil { return nil, err } diff --git a/database/db/entry_test.go b/database/db/entry_test.go index 8e5f104..92b9e45 100644 --- a/database/db/entry_test.go +++ b/database/db/entry_test.go @@ -10,21 +10,13 @@ import ( "github.com/stretchr/testify/require" ) -func createRandomEntry(t *testing.T) Entry { - filter := ListAccountsParams{ - Limit: 100, - Offset: 0, - } - - accounts, err := testQueries.ListAccounts(context.Background(), &filter) - require.NoError(t, err) - +func createRandomEntry(t *testing.T, account Account) Entry { arg := CreateEntryParams{ - AccountID: randomAccountID(accounts), + AccountID: account.ID, Amount: util.RandomMoney(), } - entry, err := testQueries.CreateEntry(context.Background(), &arg) + entry, err := testStore.CreateEntry(context.Background(), &arg) require.NoError(t, err) require.NotEmpty(t, entry) @@ -38,12 +30,14 @@ func createRandomEntry(t *testing.T) Entry { } func TestCreateEntry(t *testing.T) { - createRandomEntry(t) + account := createRandomAccount(t) + createRandomEntry(t, account) } func TestGetEntry(t *testing.T) { - entry1 := createRandomEntry(t) - entry2, err := testQueries.GetEntry(context.Background(), entry1.ID) + account := createRandomAccount(t) + entry1 := createRandomEntry(t, account) + entry2, err := testStore.GetEntry(context.Background(), entry1.ID) require.NoError(t, err) require.NotEmpty(t, entry2) @@ -55,14 +49,15 @@ func TestGetEntry(t *testing.T) { } func TestUpdateEntry(t *testing.T) { - entry1 := createRandomEntry(t) + account := createRandomAccount(t) + entry1 := createRandomEntry(t, account) arg := UpdateEntryParams{ ID: entry1.ID, Amount: util.RandomMoney(), } - entry2, err := testQueries.UpdateEntry(context.Background(), &arg) + entry2, err := testStore.UpdateEntry(context.Background(), &arg) require.NoError(t, err) require.NotEmpty(t, entry2) @@ -74,31 +69,35 @@ func TestUpdateEntry(t *testing.T) { } func TestDeleteEntry(t *testing.T) { - entry1 := createRandomEntry(t) - err := testQueries.DeleteEntry(context.Background(), entry1.ID) + account := createRandomAccount(t) + entry1 := createRandomEntry(t, account) + err := testStore.DeleteEntry(context.Background(), entry1.ID) require.NoError(t, err) - entry2, err := testQueries.GetEntry(context.Background(), entry1.ID) + entry2, err := testStore.GetEntry(context.Background(), entry1.ID) require.Error(t, err) require.EqualError(t, err, pgx.ErrNoRows.Error()) require.Empty(t, entry2) } -func TestListEntrys(t *testing.T) { +func TestListEntries(t *testing.T) { + account := createRandomAccount(t) for i := 0; i < 10; i++ { - createRandomEntry(t) + createRandomEntry(t, account) } arg := ListEntriesParams{ - Limit: 5, - Offset: 5, + AccountID: account.ID, + Limit: 5, + Offset: 5, } - entries, err := testQueries.ListEntries(context.Background(), &arg) + entries, err := testStore.ListEntries(context.Background(), &arg) require.NoError(t, err) require.Len(t, entries, 5) - for _, account := range entries { - require.NotEmpty(t, account) + for _, entry := range entries { + require.NotEmpty(t, entry) + require.Equal(t, arg.AccountID, entry.AccountID) } } diff --git a/database/db/main_test.go b/database/db/main_test.go index 84d852f..eedbea1 100644 --- a/database/db/main_test.go +++ b/database/db/main_test.go @@ -4,15 +4,13 @@ import ( "context" "log" "main/util" - "math/rand" "os" "testing" "github.com/jackc/pgx/v5/pgxpool" ) -var testQueries *Queries -var testDB *pgxpool.Pool +var testStore Store func TestMain(m *testing.M) { cfg, err := util.LoadConfig("../../.env") @@ -20,17 +18,12 @@ func TestMain(m *testing.M) { log.Fatal("cannot load config:", err) } - testDB, err = pgxpool.New(context.Background(), cfg.DatabaseURL) + connPool, err := pgxpool.New(context.Background(), cfg.DatabaseURL) if err != nil { log.Fatal("cannot connect to db:", err) } - testQueries = New(testDB) + testStore = NewStore(connPool) os.Exit(m.Run()) } - -func randomAccountID(arr []*Account) int64 { - n := len(arr) - return arr[rand.Intn(n)].ID -} diff --git a/database/db/store_test.go b/database/db/store_test.go index 1fb9d3d..289b3c7 100644 --- a/database/db/store_test.go +++ b/database/db/store_test.go @@ -9,8 +9,6 @@ import ( ) func TestTransferTx(t *testing.T) { - store := NewStore(testDB) - account1 := createRandomAccount(t) account2 := createRandomAccount(t) fmt.Println(">> before:", account1.Balance, account2.Balance) @@ -23,7 +21,7 @@ func TestTransferTx(t *testing.T) { for i := 0; i < n; i++ { go func() { - result, err := store.TransferTx(context.Background(), &CreateTransferParams{ + result, err := testStore.TransferTx(context.Background(), &CreateTransferParams{ FromAccountID: account1.ID, ToAccountID: account2.ID, Amount: amount, @@ -53,7 +51,7 @@ func TestTransferTx(t *testing.T) { require.NotZero(t, transfer.ID) require.NotZero(t, transfer.CreatedAt) - _, err = store.GetTransfer(context.Background(), transfer.ID) + _, err = testStore.GetTransfer(context.Background(), transfer.ID) require.NoError(t, err) // check entries @@ -64,7 +62,7 @@ func TestTransferTx(t *testing.T) { require.NotZero(t, fromEntry.ID) require.NotZero(t, fromEntry.CreatedAt) - _, err = store.GetEntry(context.Background(), fromEntry.ID) + _, err = testStore.GetEntry(context.Background(), fromEntry.ID) require.NoError(t, err) toEntry := result.ToEntry @@ -74,7 +72,7 @@ func TestTransferTx(t *testing.T) { require.NotZero(t, toEntry.ID) require.NotZero(t, toEntry.CreatedAt) - _, err = store.GetEntry(context.Background(), toEntry.ID) + _, err = testStore.GetEntry(context.Background(), toEntry.ID) require.NoError(t, err) // check account @@ -101,10 +99,10 @@ func TestTransferTx(t *testing.T) { } // check the final updated balances - updateAccount1, err := testQueries.GetAccount(context.Background(), account1.ID) + updateAccount1, err := testStore.GetAccount(context.Background(), account1.ID) require.NoError(t, err) - updateAccount2, err := testQueries.GetAccount(context.Background(), account2.ID) + updateAccount2, err := testStore.GetAccount(context.Background(), account2.ID) require.NoError(t, err) fmt.Println(">> after:", account1.Balance, account2.Balance) @@ -113,8 +111,6 @@ func TestTransferTx(t *testing.T) { } func TestTransferTxDeadlock(t *testing.T) { - store := NewStore(testDB) - account1 := createRandomAccount(t) account2 := createRandomAccount(t) fmt.Println(">> before:", account1.Balance, account2.Balance) @@ -134,7 +130,7 @@ func TestTransferTxDeadlock(t *testing.T) { } go func() { - _, err := store.TransferTx(context.Background(), &CreateTransferParams{ + _, err := testStore.TransferTx(context.Background(), &CreateTransferParams{ FromAccountID: fromAccountID, ToAccountID: toAccountID, Amount: amount, @@ -150,10 +146,10 @@ func TestTransferTxDeadlock(t *testing.T) { } // check the final updated balances - updateAccount1, err := testQueries.GetAccount(context.Background(), account1.ID) + updateAccount1, err := testStore.GetAccount(context.Background(), account1.ID) require.NoError(t, err) - updateAccount2, err := testQueries.GetAccount(context.Background(), account2.ID) + updateAccount2, err := testStore.GetAccount(context.Background(), account2.ID) require.NoError(t, err) fmt.Println(">> after:", account1.Balance, account2.Balance) diff --git a/database/db/transfer.sql.go b/database/db/transfer.sql.go index 937b677..f49c92e 100644 --- a/database/db/transfer.sql.go +++ b/database/db/transfer.sql.go @@ -65,18 +65,26 @@ func (q *Queries) GetTransfer(ctx context.Context, id int64) (*Transfer, error) const listTransfers = `-- name: ListTransfers :many SELECT id, from_account_id, to_account_id, amount, created_at FROM transfers +WHERE from_account_id = $1 OR to_account_id = $2 ORDER BY id -LIMIT $1 -OFFSET $2 +LIMIT $3 +OFFSET $4 ` type ListTransfersParams struct { - Limit int32 `db:"limit" json:"limit"` - Offset int32 `db:"offset" json:"offset"` + FromAccountID int64 `db:"from_account_id" json:"from_account_id"` + ToAccountID int64 `db:"to_account_id" json:"to_account_id"` + Limit int32 `db:"limit" json:"limit"` + Offset int32 `db:"offset" json:"offset"` } func (q *Queries) ListTransfers(ctx context.Context, arg *ListTransfersParams) ([]*Transfer, error) { - rows, err := q.db.Query(ctx, listTransfers, arg.Limit, arg.Offset) + rows, err := q.db.Query(ctx, listTransfers, + arg.FromAccountID, + arg.ToAccountID, + arg.Limit, + arg.Offset, + ) if err != nil { return nil, err } diff --git a/database/db/transfer_test.go b/database/db/transfer_test.go index c64fdec..08994d9 100644 --- a/database/db/transfer_test.go +++ b/database/db/transfer_test.go @@ -10,29 +10,15 @@ import ( "github.com/stretchr/testify/require" ) -func createRandomTransfer(t *testing.T) Transfer { - filter := ListAccountsParams{ - Limit: 100, - Offset: 0, - } - - accounts, err := testQueries.ListAccounts(context.Background(), &filter) - require.NoError(t, err) - - fromAccountID := randomAccountID(accounts) - toAccountID := randomAccountID(accounts) - - for toAccountID == fromAccountID { - toAccountID = randomAccountID(accounts) - } +func createRandomTransfer(t *testing.T, account1, account2 Account) Transfer { arg := CreateTransferParams{ - FromAccountID: fromAccountID, - ToAccountID: toAccountID, + FromAccountID: account1.ID, + ToAccountID: account2.ID, Amount: util.RandomMoney(), } - transfer, err := testQueries.CreateTransfer(context.Background(), &arg) + transfer, err := testStore.CreateTransfer(context.Background(), &arg) require.NoError(t, err) require.NotEmpty(t, transfer) @@ -47,12 +33,16 @@ func createRandomTransfer(t *testing.T) Transfer { } func TestCreateTransfer(t *testing.T) { - createRandomTransfer(t) + account1 := createRandomAccount(t) + account2 := createRandomAccount(t) + createRandomTransfer(t, account1, account2) } func TestGetTransfer(t *testing.T) { - transfer1 := createRandomTransfer(t) - transfer2, err := testQueries.GetTransfer(context.Background(), transfer1.ID) + account1 := createRandomAccount(t) + account2 := createRandomAccount(t) + transfer1 := createRandomTransfer(t, account1, account2) + transfer2, err := testStore.GetTransfer(context.Background(), transfer1.ID) require.NoError(t, err) require.NotEmpty(t, transfer2) @@ -65,67 +55,64 @@ func TestGetTransfer(t *testing.T) { } func TestUpdateTransfer(t *testing.T) { - transfer1 := createRandomTransfer(t) - - filter := ListAccountsParams{ - Limit: 100, - Offset: 0, - } - - accounts, err := testQueries.ListAccounts(context.Background(), &filter) - require.NoError(t, err) - - toAccountID := randomAccountID(accounts) - - for toAccountID == transfer1.FromAccountID { - toAccountID = randomAccountID(accounts) - } + account1 := createRandomAccount(t) + account2 := createRandomAccount(t) + transfer1 := createRandomTransfer(t, account1, account2) arg := UpdateTransferParams{ ID: transfer1.ID, FromAccountID: transfer1.FromAccountID, - ToAccountID: toAccountID, + ToAccountID: transfer1.ToAccountID, Amount: util.RandomMoney(), } - transfer2, err := testQueries.UpdateTransfer(context.Background(), &arg) + transfer2, err := testStore.UpdateTransfer(context.Background(), &arg) require.NoError(t, err) require.NotEmpty(t, transfer2) require.Equal(t, transfer1.ID, transfer2.ID) require.Equal(t, transfer1.FromAccountID, transfer2.FromAccountID) - require.Equal(t, arg.ToAccountID, transfer2.ToAccountID) + require.Equal(t, transfer1.ToAccountID, transfer2.ToAccountID) require.Equal(t, arg.Amount, transfer2.Amount) require.WithinDuration(t, transfer1.CreatedAt, transfer2.CreatedAt, time.Second) } func TestDeleteTransfer(t *testing.T) { - transfer1 := createRandomTransfer(t) - err := testQueries.DeleteTransfer(context.Background(), transfer1.ID) + account1 := createRandomAccount(t) + account2 := createRandomAccount(t) + transfer1 := createRandomTransfer(t, account1, account2) + err := testStore.DeleteTransfer(context.Background(), transfer1.ID) require.NoError(t, err) - transfer2, err := testQueries.GetTransfer(context.Background(), transfer1.ID) + transfer2, err := testStore.GetTransfer(context.Background(), transfer1.ID) require.Error(t, err) require.EqualError(t, err, pgx.ErrNoRows.Error()) require.Empty(t, transfer2) } func TestListTransfers(t *testing.T) { - for i := 0; i < 10; i++ { - createRandomTransfer(t) + account1 := createRandomAccount(t) + account2 := createRandomAccount(t) + + for i := 0; i < 5; i++ { + createRandomTransfer(t, account1, account2) + createRandomTransfer(t, account2, account1) } arg := ListTransfersParams{ - Limit: 5, - Offset: 5, + FromAccountID: account1.ID, + ToAccountID: account1.ID, + Limit: 5, + Offset: 5, } - transfers, err := testQueries.ListTransfers(context.Background(), &arg) + transfers, err := testStore.ListTransfers(context.Background(), &arg) require.NoError(t, err) require.Len(t, transfers, 5) for _, transfer := range transfers { require.NotEmpty(t, transfer) + require.True(t, transfer.FromAccountID == account1.ID || transfer.ToAccountID == account1.ID) } } diff --git a/database/db/user_test.go b/database/db/user_test.go index 3f10470..feba3b4 100644 --- a/database/db/user_test.go +++ b/database/db/user_test.go @@ -20,7 +20,7 @@ func createRandomUser(t *testing.T) *User { Email: util.RandomEmail(), } - user, err := testQueries.CreateUser(context.Background(), &arg) + user, err := testStore.CreateUser(context.Background(), &arg) require.NoError(t, err) require.NotEmpty(t, user) @@ -41,7 +41,7 @@ func TestCreateUser(t *testing.T) { func TestGetUser(t *testing.T) { user1 := createRandomUser(t) - user2, err := testQueries.GetUser(context.Background(), user1.Username) + user2, err := testStore.GetUser(context.Background(), user1.Username) require.NoError(t, err) require.NotEmpty(t, user2) diff --git a/database/query/entry.sql b/database/query/entry.sql index 582e227..b41a2c8 100644 --- a/database/query/entry.sql +++ b/database/query/entry.sql @@ -10,9 +10,10 @@ LIMIT 1; -- name: ListEntries :many SELECT * FROM entries +WHERE account_id = $1 ORDER BY id -LIMIT $1 -OFFSET $2; +LIMIT $2 +OFFSET $3; -- name: UpdateEntry :one UPDATE entries diff --git a/database/query/transfer.sql b/database/query/transfer.sql index 4c2b738..936884b 100644 --- a/database/query/transfer.sql +++ b/database/query/transfer.sql @@ -10,9 +10,10 @@ LIMIT 1; -- name: ListTransfers :many SELECT * FROM transfers +WHERE from_account_id = $1 OR to_account_id = $2 ORDER BY id -LIMIT $1 -OFFSET $2; +LIMIT $3 +OFFSET $4; -- name: UpdateTransfer :one UPDATE transfers