From e7ae304a08ed9cd877d436e9730d0a94366442df Mon Sep 17 00:00:00 2001 From: Emir Ribic Date: Mon, 29 Oct 2018 15:01:41 +0100 Subject: [PATCH] Major refactor - ribice.ba/re-gorsk --- Gopkg.lock | 224 +++++++--- Gopkg.toml | 28 +- README.MD | 324 +++++++------- .../swaggerui/favicon-16x16.png | Bin .../swaggerui/favicon-32x32.png | Bin {cmd/api => assets}/swaggerui/index.html | 0 .../swaggerui/oauth2-redirect.html | 0 .../swaggerui/swagger-ui-bundle.js | 0 .../swaggerui/swagger-ui-bundle.js.map | 0 .../swaggerui/swagger-ui-standalone-preset.js | 0 .../swagger-ui-standalone-preset.js.map | 0 {cmd/api => assets}/swaggerui/swagger-ui.css | 0 .../swaggerui/swagger-ui.css.map | 0 {cmd/api => assets}/swaggerui/swagger-ui.js | 0 .../swaggerui/swagger-ui.js.map | 0 {cmd/api => assets}/swaggerui/swagger.json | 0 cmd/api/conf.local.yaml | 21 + cmd/api/config/config.go | 58 --- cmd/api/config/files/config.dev.yaml | 16 - cmd/api/config/files/config.testdata.yaml | 10 - cmd/api/main.go | 87 +--- cmd/api/request/account.go | 62 --- cmd/api/request/account_test.go | 115 ----- cmd/api/request/auth.go | 21 - cmd/api/request/auth_test.go | 46 -- cmd/api/request/request.go | 46 -- cmd/api/request/request_test.go | 100 ----- cmd/api/request/user.go | 29 -- cmd/api/request/user_test.go | 61 --- cmd/api/service/account.go | 91 ---- cmd/api/service/account_test.go | 182 -------- cmd/api/swagger/user.go | 45 -- cmd/migration/main.go | 28 +- internal/account/account.go | 55 --- internal/account/account_test.go | 176 -------- internal/auth/auth.go | 111 ----- internal/mock/auth.go | 17 - internal/mock/mockdb/account.go | 22 - internal/mock/mockdb/user.go | 46 -- internal/mock/mw.go | 15 - internal/model_test.go | 34 -- internal/platform/postgres/account.go | 46 -- internal/platform/postgres/account_test.go | 164 ------- internal/platform/postgres/pg.go | 55 --- internal/platform/postgres/pg_test.go | 115 ----- internal/platform/postgres/user.go | 88 ---- internal/platform/postgres/user_test.go | 409 ------------------ internal/platform/query/query.go | 20 - internal/user.go | 61 --- internal/user/user.go | 77 ---- internal/user_test.go | 17 - pkg/api/api.go | 83 ++++ pkg/api/auth/auth.go | 62 +++ {internal => pkg/api}/auth/auth_test.go | 202 +++++---- pkg/api/auth/platform/pgsql/user.go | 57 +++ pkg/api/auth/platform/pgsql/user_test.go | 287 ++++++++++++ pkg/api/auth/service.go | 65 +++ .../auth.go => pkg/api/auth/transport/http.go | 46 +- .../api/auth/transport/http_test.go | 106 +++-- .../api/auth/transport/swagger.go | 11 +- pkg/api/password/password.go | 37 ++ pkg/api/password/password_test.go | 148 +++++++ pkg/api/password/platform/pgsql/user.go | 29 ++ pkg/api/password/platform/pgsql/user_test.go | 149 +++++++ pkg/api/password/service.go | 55 +++ pkg/api/password/transport/http.go | 88 ++++ pkg/api/password/transport/http_test.go | 117 +++++ pkg/api/user/platform/pgsql/user.go | 79 ++++ pkg/api/user/platform/pgsql/user_test.go | 400 +++++++++++++++++ pkg/api/user/service.go | 58 +++ .../user.go => pkg/api/user/transport/http.go | 161 +++++-- .../api/user/transport/http_test.go | 262 +++++++---- pkg/api/user/transport/swagger.go | 24 + pkg/api/user/user.go | 77 ++++ {internal => pkg/api}/user/user_test.go | 278 ++++++++---- pkg/utl/config/config.go | 59 +++ {cmd/api => pkg/utl}/config/config_test.go | 37 +- .../utl/config/testdata}/config.invalid.yaml | 0 pkg/utl/config/testdata/config.testdata.yaml | 21 + {cmd/api/mw => pkg/utl/middleware/jwt}/jwt.go | 57 +-- .../mw => pkg/utl/middleware/jwt}/jwt_test.go | 51 ++- .../utl/middleware/secure/secure.go | 13 +- .../utl/middleware/secure/secure_test.go | 25 +- {internal => pkg/utl}/mock/mock.go | 32 +- pkg/utl/mock/mockdb/user.go | 52 +++ pkg/utl/mock/mw.go | 15 + pkg/utl/mock/postgres.go | 54 +++ {internal => pkg/utl}/mock/rbac.go | 21 +- pkg/utl/mock/secure.go | 29 ++ {internal => pkg/utl/model}/auth.go | 8 +- {internal => pkg/utl/model}/company.go | 2 +- pkg/utl/model/error.go | 18 + {internal => pkg/utl/model}/location.go | 2 +- {internal => pkg/utl/model}/model.go | 20 +- pkg/utl/model/model_test.go | 57 +++ pkg/utl/model/pagination.go | 32 ++ {internal => pkg/utl/model}/role.go | 14 +- {cmd/api/swagger => pkg/utl/model}/swagger.go | 2 +- pkg/utl/model/user.go | 54 +++ pkg/utl/model/user_test.go | 43 ++ pkg/utl/postgres/pg.go | 39 ++ pkg/utl/postgres/pg_test.go | 58 +++ pkg/utl/query/query.go | 20 + .../platform => pkg/utl}/query/query_test.go | 30 +- {internal => pkg/utl}/rbac/rbac.go | 48 +- {internal => pkg/utl}/rbac/rbac_test.go | 88 ++-- pkg/utl/secure/secure.go | 46 ++ pkg/utl/secure/secure_test.go | 71 +++ {cmd/api => pkg/utl}/server/binding.go | 13 +- {cmd/api => pkg/utl}/server/binding_test.go | 6 +- {cmd/api => pkg/utl}/server/error.go | 0 {cmd/api => pkg/utl}/server/server.go | 25 +- {cmd/api => pkg/utl}/server/server_test.go | 2 +- .../platform => pkg/utl}/structs/merge.go | 0 .../utl}/structs/merge_test.go | 2 +- test.sh | 2 +- 116 files changed, 3822 insertions(+), 3349 deletions(-) rename {cmd/api => assets}/swaggerui/favicon-16x16.png (100%) rename {cmd/api => assets}/swaggerui/favicon-32x32.png (100%) rename {cmd/api => assets}/swaggerui/index.html (100%) rename {cmd/api => assets}/swaggerui/oauth2-redirect.html (100%) rename {cmd/api => assets}/swaggerui/swagger-ui-bundle.js (100%) rename {cmd/api => assets}/swaggerui/swagger-ui-bundle.js.map (100%) rename {cmd/api => assets}/swaggerui/swagger-ui-standalone-preset.js (100%) rename {cmd/api => assets}/swaggerui/swagger-ui-standalone-preset.js.map (100%) rename {cmd/api => assets}/swaggerui/swagger-ui.css (100%) rename {cmd/api => assets}/swaggerui/swagger-ui.css.map (100%) rename {cmd/api => assets}/swaggerui/swagger-ui.js (100%) rename {cmd/api => assets}/swaggerui/swagger-ui.js.map (100%) rename {cmd/api => assets}/swaggerui/swagger.json (100%) create mode 100644 cmd/api/conf.local.yaml delete mode 100644 cmd/api/config/config.go delete mode 100644 cmd/api/config/files/config.dev.yaml delete mode 100644 cmd/api/config/files/config.testdata.yaml delete mode 100644 cmd/api/request/account.go delete mode 100644 cmd/api/request/account_test.go delete mode 100644 cmd/api/request/auth.go delete mode 100644 cmd/api/request/auth_test.go delete mode 100644 cmd/api/request/request.go delete mode 100644 cmd/api/request/request_test.go delete mode 100644 cmd/api/request/user.go delete mode 100644 cmd/api/request/user_test.go delete mode 100644 cmd/api/service/account.go delete mode 100644 cmd/api/service/account_test.go delete mode 100644 cmd/api/swagger/user.go delete mode 100644 internal/account/account.go delete mode 100644 internal/account/account_test.go delete mode 100644 internal/auth/auth.go delete mode 100644 internal/mock/auth.go delete mode 100644 internal/mock/mockdb/account.go delete mode 100644 internal/mock/mockdb/user.go delete mode 100644 internal/mock/mw.go delete mode 100644 internal/model_test.go delete mode 100644 internal/platform/postgres/account.go delete mode 100644 internal/platform/postgres/account_test.go delete mode 100644 internal/platform/postgres/pg.go delete mode 100644 internal/platform/postgres/pg_test.go delete mode 100644 internal/platform/postgres/user.go delete mode 100644 internal/platform/postgres/user_test.go delete mode 100644 internal/platform/query/query.go delete mode 100644 internal/user.go delete mode 100644 internal/user/user.go delete mode 100644 internal/user_test.go create mode 100644 pkg/api/api.go create mode 100644 pkg/api/auth/auth.go rename {internal => pkg/api}/auth/auth_test.go (53%) create mode 100644 pkg/api/auth/platform/pgsql/user.go create mode 100644 pkg/api/auth/platform/pgsql/user_test.go create mode 100644 pkg/api/auth/service.go rename cmd/api/service/auth.go => pkg/api/auth/transport/http.go (57%) rename cmd/api/service/auth_test.go => pkg/api/auth/transport/http_test.go (63%) rename cmd/api/swagger/auth.go => pkg/api/auth/transport/swagger.go (67%) create mode 100644 pkg/api/password/password.go create mode 100644 pkg/api/password/password_test.go create mode 100644 pkg/api/password/platform/pgsql/user.go create mode 100644 pkg/api/password/platform/pgsql/user_test.go create mode 100644 pkg/api/password/service.go create mode 100644 pkg/api/password/transport/http.go create mode 100644 pkg/api/password/transport/http_test.go create mode 100644 pkg/api/user/platform/pgsql/user.go create mode 100644 pkg/api/user/platform/pgsql/user_test.go create mode 100644 pkg/api/user/service.go rename cmd/api/service/user.go => pkg/api/user/transport/http.go (51%) rename cmd/api/service/user_test.go => pkg/api/user/transport/http_test.go (54%) create mode 100644 pkg/api/user/transport/swagger.go create mode 100644 pkg/api/user/user.go rename {internal => pkg/api}/user/user_test.go (53%) create mode 100644 pkg/utl/config/config.go rename {cmd/api => pkg/utl}/config/config_test.go (50%) rename {cmd/api/config/files => pkg/utl/config/testdata}/config.invalid.yaml (100%) create mode 100644 pkg/utl/config/testdata/config.testdata.yaml rename {cmd/api/mw => pkg/utl/middleware/jwt}/jwt.go (58%) rename {cmd/api/mw => pkg/utl/middleware/jwt}/jwt_test.go (75%) rename cmd/api/mw/mw.go => pkg/utl/middleware/secure/secure.go (82%) rename cmd/api/mw/mw_test.go => pkg/utl/middleware/secure/secure_test.go (75%) rename {internal => pkg/utl}/mock/mock.go (76%) create mode 100644 pkg/utl/mock/mockdb/user.go create mode 100644 pkg/utl/mock/mw.go create mode 100644 pkg/utl/mock/postgres.go rename {internal => pkg/utl}/mock/rbac.go (60%) create mode 100644 pkg/utl/mock/secure.go rename {internal => pkg/utl/model}/auth.go (88%) rename {internal => pkg/utl/model}/company.go (94%) create mode 100644 pkg/utl/model/error.go rename {internal => pkg/utl/model}/location.go (93%) rename {internal => pkg/utl/model}/model.go (58%) create mode 100644 pkg/utl/model/model_test.go create mode 100644 pkg/utl/model/pagination.go rename {internal => pkg/utl/model}/role.go (69%) rename {cmd/api/swagger => pkg/utl/model}/swagger.go (94%) create mode 100644 pkg/utl/model/user.go create mode 100644 pkg/utl/model/user_test.go create mode 100644 pkg/utl/postgres/pg.go create mode 100644 pkg/utl/postgres/pg_test.go create mode 100644 pkg/utl/query/query.go rename {internal/platform => pkg/utl}/query/query_test.go (61%) rename {internal => pkg/utl}/rbac/rbac.go (62%) rename {internal => pkg/utl}/rbac/rbac_test.go (67%) create mode 100644 pkg/utl/secure/secure.go create mode 100644 pkg/utl/secure/secure_test.go rename {cmd/api => pkg/utl}/server/binding.go (67%) rename {cmd/api => pkg/utl}/server/binding_test.go (89%) rename {cmd/api => pkg/utl}/server/error.go (100%) rename {cmd/api => pkg/utl}/server/server.go (64%) rename {cmd/api => pkg/utl}/server/server_test.go (80%) rename {internal/platform => pkg/utl}/structs/merge.go (100%) rename {internal/platform => pkg/utl}/structs/merge_test.go (99%) diff --git a/Gopkg.lock b/Gopkg.lock index 0fae962..7657ca6 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -2,27 +2,27 @@ [[projects]] - digest = "1:a2c1d0e43bd3baaa071d1b9ed72c27d78169b2b269f71c105ac4ba34b1be4a39" + digest = "1:ffe9824d294da03b391f44e1ae8281281b4afc1bdaa9588c9097785e3af10cec" name = "github.com/davecgh/go-spew" packages = ["spew"] - pruneopts = "UT" - revision = "346938d642f2ec3594ed81d874461961cd0faa76" - version = "v1.1.0" + pruneopts = "NUT" + revision = "8991bc29aa16c548c550c7ff78260e27b9ab7c73" + version = "v1.1.1" [[projects]] - digest = "1:76dc72490af7174349349838f2fe118996381b31ea83243812a97e5a0fd5ed55" + digest = "1:7a6852b35eb5bbc184561443762d225116ae630c26a7c4d90546619f1e7d2ad2" name = "github.com/dgrijalva/jwt-go" packages = ["."] - pruneopts = "UT" + pruneopts = "NUT" revision = "06ea1031745cb8b3dab3f6a236daf2b0aa468b7e" version = "v3.2.0" [[projects]] branch = "master" - digest = "1:5fe3f6ede1c208a2efd3b78fe4df0306aa9624edd39476143d14f0326e5a8d29" + digest = "1:b29ac3fe6b4601f89b589337c584ab7204a5c72932465e5f4a16772b5560b4a7" name = "github.com/facebookgo/clock" packages = ["."] - pruneopts = "UT" + pruneopts = "NUT" revision = "600d898af40aa09a7a93ecb9265d87b0504b6f03" [[projects]] @@ -30,27 +30,48 @@ digest = "1:17cb421603403a24edb0c7eeb382295dd31c72ab9ccf31cf7f0a37971f00aaa7" name = "github.com/facebookgo/httpdown" packages = ["."] - pruneopts = "UT" + pruneopts = "NUT" revision = "5979d39b15c26299dc282711b0d65b113daccea6" [[projects]] branch = "master" - digest = "1:02c7a4e944d94d6b80f51517158d10633b10775f528a3b7bdfc658d6f92415bd" + digest = "1:35a271df55b343e440407daac25d877269e3bb2f60d2031bcc339bcbe6c0f9b9" name = "github.com/facebookgo/stats" packages = ["."] - pruneopts = "UT" + pruneopts = "NUT" revision = "1b76add642e42c6ffba7211ad7b3939ce654526e" [[projects]] branch = "master" - digest = "1:ca0831a9c3eefdd659de2a1f02d0ee08f3630a11b5d5c098cc8aef4b52ff20fd" + digest = "1:b792ca62f4973ab6938f9e63a6f45569efc1cb26ad7b6cf1786a89c535132b6a" name = "github.com/fortytw2/dockertest" packages = ["."] - pruneopts = "UT" + pruneopts = "NUT" revision = "a73397bdeff4b713648d4b2e7fc655eb9a85fb6b" [[projects]] - digest = "1:5b14f08247db438a2d11b77aff373b6536028673e38eab87fb5062e07b3b7487" + branch = "master" + digest = "1:23edf5aaca7a1f15d68215ac41595c0732152757e9d7b3f282821222784049f1" + name = "github.com/gin-contrib/sse" + packages = ["."] + pruneopts = "NUT" + revision = "22d885f9ecc78bf4ee5d72b937e4bbcdc58e8cae" + +[[projects]] + digest = "1:7f029513a3c64c1fc923af79bf27d4fd0ce4fafdd13b421c1884d52c84e9c600" + name = "github.com/gin-gonic/gin" + packages = [ + ".", + "binding", + "json", + "render", + ] + pruneopts = "NUT" + revision = "b869fe1415e4b9eb52f247441830d502aece2d4d" + version = "v1.3.0" + +[[projects]] + digest = "1:8852fcfc55e0418d742862a1848e76b34f209b762134ba2fe821551bdb0b375f" name = "github.com/go-pg/pg" packages = [ ".", @@ -60,58 +81,74 @@ "orm", "types", ] - pruneopts = "UT" - revision = "c0368cd6ee62269f0a5c3d6c9c02c6d192760bc6" - version = "v6.14.3" + pruneopts = "NUT" + revision = "514bed76d8f579d6ff8d40294fa77e476a5c1b3f" + version = "v6.15.0" [[projects]] - digest = "1:e1ff887e232b2d8f4f7c7db15a5fac7be418025afc4dda53c59c765dbb5aa6b4" + digest = "1:1c109e91a99627f9c80e8794d80773c8532dfcf234b45cdb6a6388b30a5bb5e7" name = "github.com/go-playground/locales" packages = [ ".", "currency", ] - pruneopts = "UT" + pruneopts = "NUT" revision = "f63010822830b6fe52288ee52d5a1151088ce039" version = "v0.12.1" [[projects]] - digest = "1:e022cf244bcac1b6ef933f1a2e0adcf6a6dfd7b872d8d41e4d4179bb09a87cbc" + digest = "1:1683152827ebac377858b53a6ad0be90fb1711061c7e580c5dc719834a349162" name = "github.com/go-playground/universal-translator" packages = ["."] - pruneopts = "UT" + pruneopts = "NUT" revision = "b32fa301c9fe55953584134cb6853a13c87ec0a1" version = "v0.16.0" [[projects]] - digest = "1:a351fd4cb95e313ecb345a521f276e7a00913d1f2e84c61543bc4fa1785ba7f3" + digest = "1:813792cd18ed752eea76bd63abc0b5ebd3329fb9d645eacc9b37da9027b7c673" name = "github.com/go-playground/validator" packages = ["."] - pruneopts = "UT" - revision = "ab2a8a99087e827c9af87ed6777ba897348fb178" - version = "v9.20.2" + pruneopts = "NUT" + revision = "e69e9a28bb62b977fdc58d051f1bb477b7cbe486" + version = "v9.21.0" + +[[projects]] + digest = "1:97df918963298c287643883209a2c3f642e6593379f97ab400c2a2e219ab647d" + name = "github.com/golang/protobuf" + packages = ["proto"] + pruneopts = "NUT" + revision = "aa810b61a9c79d51363740d207bb46cf8e620ed5" + version = "v1.2.0" [[projects]] branch = "master" - digest = "1:fd97437fbb6b7dce04132cf06775bd258cce305c44add58eb55ca86c6c325160" + digest = "1:802f75230c29108e787d40679f9bf5da1a5673eaf5c10eb89afd993e18972909" name = "github.com/jinzhu/inflection" packages = ["."] - pruneopts = "UT" + pruneopts = "NUT" revision = "04140366298a54a039076d798123ffa108fff46c" [[projects]] - digest = "1:87f801436ec4799e0e043ab348ff9f3e95b1b3761138158f0950ac67695cc272" + digest = "1:8e36686e8b139f8fe240c1d5cf3a145bc675c22ff8e707857cdd3ae17b00d728" + name = "github.com/json-iterator/go" + packages = ["."] + pruneopts = "NUT" + revision = "1624edc4454b8682399def8740d46db5e4362ba4" + version = "v1.1.5" + +[[projects]] + digest = "1:0f7251e66e237eff3aa62a70890f67a3364b4a77c3a496f18773d6cd4b50938c" name = "github.com/labstack/echo" packages = [ ".", "middleware", ] - pruneopts = "UT" - revision = "6d227dfea4d2e52cb76856120b3c17f758139b4e" - version = "3.3.5" + pruneopts = "NUT" + revision = "1abaa3049251d17932e4313c2d6165073fd07fd8" + version = "v3.3.6" [[projects]] - digest = "1:568171fc14a3d819b112c3e219d351ea7b05e8dad7935c4168c6b3373244a686" + digest = "1:42ecba6280172b66005991519361e5c0505d89f9685c0d4311f9ec546610d453" name = "github.com/labstack/gommon" packages = [ "bytes", @@ -119,106 +156,156 @@ "log", "random", ] - pruneopts = "UT" - revision = "d6898124de917583f5ff5592ef931d1dfe0ddc05" - version = "0.2.6" + pruneopts = "NUT" + revision = "2a618302b929cc20862dda3aa6f02f64dbe740dd" + version = "v0.2.7" [[projects]] branch = "master" - digest = "1:37ce7d7d80531b227023331002c0d42b4b4b291a96798c82a049d03a54ba79e4" + digest = "1:784d05a21c37defeaba624c1b0a55cb2c6488dbc9f456df16aab1af4928618c6" name = "github.com/lib/pq" packages = [ ".", "oid", ] - pruneopts = "UT" - revision = "90697d60dd844d5ef6ff15135d0203f65d2f53b8" + pruneopts = "NUT" + revision = "9eb73efc1fcc404148b56765b0d3f61d9a5ef8ee" [[projects]] - digest = "1:c658e84ad3916da105a761660dcaeb01e63416c8ec7bc62256a9b411a05fcd67" + digest = "1:08c231ec84231a7e23d67e4b58f975e1423695a32467a362ee55a803f9de8061" name = "github.com/mattn/go-colorable" packages = ["."] - pruneopts = "UT" + pruneopts = "NUT" revision = "167de6bfdfba052fa6b2d3664c8f5272e23c9072" version = "v0.0.9" [[projects]] - digest = "1:d4d17353dbd05cb52a2a52b7fe1771883b682806f68db442b436294926bbfafb" + digest = "1:bffa444ca07c69c599ae5876bc18b25bfd5fa85b297ca10a25594d284a7e9c5d" name = "github.com/mattn/go-isatty" packages = ["."] - pruneopts = "UT" - revision = "0360b2af4f38e8d38c7fce2a9f4e702702d73a39" - version = "v0.0.3" + pruneopts = "NUT" + revision = "6ca4dbf54d38eea1a992b3c722a76a5d1c4cb25c" + version = "v0.0.4" + +[[projects]] + digest = "1:2f42fa12d6911c7b7659738758631bec870b7e9b4c6be5444f963cdcfccc191f" + name = "github.com/modern-go/concurrent" + packages = ["."] + pruneopts = "NUT" + revision = "bacd9c7ef1dd9b15be4a9909b8ac7a4e313eec94" + version = "1.0.3" + +[[projects]] + digest = "1:c6aca19413b13dc59c220ad7430329e2ec454cc310bc6d8de2c7e2b93c18a0f6" + name = "github.com/modern-go/reflect2" + packages = ["."] + pruneopts = "NUT" + revision = "4b7aa43c6742a2c18fdef89dd197aaae7dac7ccd" + version = "1.0.1" [[projects]] digest = "1:0028cb19b2e4c3112225cd871870f2d9cf49b9b4276531f03438a88e94be86fe" name = "github.com/pmezard/go-difflib" packages = ["difflib"] - pruneopts = "UT" + pruneopts = "NUT" revision = "792786c7400a136282c1664665ae0a8db921c6c2" version = "v1.0.0" [[projects]] - digest = "1:757b110984b77e820e01c60d3ac03a376a0fdb05c990dd9d6bd4f9ba0d606261" + branch = "master" + digest = "1:d156977c0bff74721acfec69f4bdfe57ae3837bac2626feb8861a181e00f78e4" + name = "github.com/ribice/gorsk-gin" + packages = [ + "cmd/api/request", + "internal", + "internal/errors", + ] + pruneopts = "NUT" + revision = "66321457570def0b7cbacd26b4edcc05bd7543cd" + +[[projects]] + digest = "1:0975c74a2cd70df6c2ae353c6283a25ce759dda7e1e706e5c07458baf3faca22" name = "github.com/rs/xid" packages = ["."] - pruneopts = "UT" - revision = "2c7e97ce663ff82c49656bca3048df0fdd83c5f9" - version = "v1.2.0" + pruneopts = "NUT" + revision = "15d26544def341f036c5f8dca987a4cbe575032c" + version = "v1.2.1" [[projects]] - digest = "1:18752d0b95816a1b777505a97f71c7467a8445b8ffb55631a7bf779f6ba4fa83" + digest = "1:bacb8b590716ab7c33f2277240972c9582d389593ee8d66fc10074e0508b8126" name = "github.com/stretchr/testify" packages = ["assert"] - pruneopts = "UT" + pruneopts = "NUT" revision = "f35b8ab0b5a2cef36673838d662e249dd9c94686" version = "v1.2.2" [[projects]] - branch = "master" - digest = "1:c468422f334a6b46a19448ad59aaffdfc0a36b08fdcc1c749a0b29b6453d7e59" + digest = "1:919fc2a81add8ac4a7a236dceaf3e4c29ba4df96d13c3f2ce13a4d0132ebefb8" + name = "github.com/ugorji/go" + packages = ["codec"] + pruneopts = "NUT" + revision = "b4c50a2b199d93b13dc15e78929cfb23bfdf21ab" + version = "v1.1.1" + +[[projects]] + digest = "1:af354641f23332b1a8d5d361d11b5a9171589e412a67d499633706c611acc064" name = "github.com/valyala/bytebufferpool" packages = ["."] - pruneopts = "UT" + pruneopts = "NUT" revision = "e746df99fe4a3986f4d4f79e13c1e0117ce9c2f7" + version = "v1.0.0" [[projects]] branch = "master" - digest = "1:268b8bce0064e8c057d7b913605459f9a26dcab864c0886a56d196540fbf003f" + digest = "1:f17377eef01c91aee2d0f93095d672bd27697c62e5546bde97f9c8412c141356" name = "github.com/valyala/fasttemplate" packages = ["."] - pruneopts = "UT" + pruneopts = "NUT" revision = "dcecefd839c4193db0d35b88ec65b4c12d360ab0" [[projects]] branch = "master" - digest = "1:93f36793477e25b53507074bce6282e96efe7028b9adc8492c52bdb72f192328" + digest = "1:c10b0f80f5d0972bbceb2bd727eff2ba86070bbdd0ed2e1c547e720fcb9bda27" + name = "github.com/vmihailenco/sasl" + packages = ["."] + pruneopts = "NUT" + revision = "2f13c189728a02f8cc31b3b9cc06047b383c21cc" + +[[projects]] + branch = "master" + digest = "1:4540f9690098dde6d0cb647b1b76ecbbecc78b3ddb138370ee3a2c7a3a49e4ec" name = "golang.org/x/crypto" packages = [ "acme", "acme/autocert", "bcrypt", "blowfish", + "pbkdf2", ] - pruneopts = "UT" - revision = "c126467f60eb25f8f27e5a981f32a87e3965053f" + pruneopts = "NUT" + revision = "0c41d7ab0a0ee717d4590a44bcb987dfd9e183eb" [[projects]] branch = "master" - digest = "1:8742e6e73627b2877c3f723bc1823d5667ec59011242480309dc90fa862512aa" + digest = "1:e1e75018db0765a0ac54aa7f9473417a7db770c75c9527b4bfff03d2e55f0a0a" name = "golang.org/x/sys" - packages = [ - "unix", - "windows", - ] - pruneopts = "UT" - revision = "bd9dbc187b6e1dacfdd2722a87e83093c2d7bd6e" + packages = ["unix"] + pruneopts = "NUT" + revision = "fa43e7bc11baaae89f3f902b2b4d832b68234844" + +[[projects]] + digest = "1:0215407129c5f116ae8f6d3af64df59c39d3f606a72ef77a1e6ed874f92a8d9c" + name = "gopkg.in/go-playground/validator.v8" + packages = ["."] + pruneopts = "NUT" + revision = "5f1438d3fca68893a817e4a66806cea46a9e4ebf" + version = "v8.18.2" [[projects]] - digest = "1:342378ac4dcb378a5448dd723f0784ae519383532f5e70ade24132c4c8693202" + digest = "1:7c95b35057a0ff2e19f707173cc1a947fa43a6eb5c4d300d196ece0334046082" name = "gopkg.in/yaml.v2" packages = ["."] - pruneopts = "UT" + pruneopts = "NUT" revision = "5420a8b6744d3b0345ab293f6fcba19c978f1183" version = "v2.2.1" @@ -234,6 +321,7 @@ "github.com/labstack/echo", "github.com/labstack/echo/middleware", "github.com/lib/pq", + "github.com/ribice/gorsk-gin/cmd/api/request", "github.com/rs/xid", "github.com/stretchr/testify/assert", "golang.org/x/crypto/bcrypt", diff --git a/Gopkg.toml b/Gopkg.toml index 8caaded..117fc5a 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -1,30 +1,3 @@ -# Gopkg.toml example -# -# Refer to https://golang.github.io/dep/docs/Gopkg.toml.html -# for detailed Gopkg.toml documentation. -# -# required = ["github.com/user/thing/cmd/thing"] -# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"] -# -# [[constraint]] -# name = "github.com/user/project" -# version = "1.0.0" -# -# [[constraint]] -# name = "github.com/user/project2" -# branch = "dev" -# source = "github.com/myfork/project2" -# -# [[override]] -# name = "github.com/x/y" -# version = "2.4.0" -# -# [prune] -# non-go = false -# go-tests = true -# unused-packages = true - - [[constraint]] name = "github.com/dgrijalva/jwt-go" version = "3.2.0" @@ -68,3 +41,4 @@ [prune] go-tests = true unused-packages = true + non-go = true diff --git a/README.MD b/README.MD index 1a5fde0..7532950 100644 --- a/README.MD +++ b/README.MD @@ -1,162 +1,162 @@ -# GORSK - GO(lang) Restful Starter Kit - -[![Build Status](https://travis-ci.org/ribice/gorsk.svg?branch=master)](https://travis-ci.org/ribice/gorsk) -[![codecov](https://codecov.io/gh/ribice/gorsk/branch/master/graph/badge.svg)](https://codecov.io/gh/ribice/gorsk) -[![Go Report Card](https://goreportcard.com/badge/github.com/ribice/gorsk)](https://goreportcard.com/report/github.com/ribice/gorsk) -[![Maintainability](https://api.codeclimate.com/v1/badges/c3cb09dbc0bc43186464/maintainability)](https://codeclimate.com/github/ribice/gorsk/maintainability) - -Gorsk is a Golang starter kit for developing RESTful services. It is designed to help you kickstart your project, skipping the 'setting-up part' and jumping straight to writing business logic. - -Previously Gorsk was built using [Gin](https://github.com/gin-gonic/gin). Gorsk using Gin is available [HERE](https://github.com/ribice/gorsk-gin). - -Gorsk follows SOLID principles, with package design being inspired by Ben Johnson's [Standard Package Layout](https://medium.com/@benbjohnson/standard-package-layout-7cdbc8391fc1). The idea for building this project and its readme structure was inspired by [this](https://github.com/qiangxue/golang-restful-starter-kit). - -This starter kit currently provides: - -* Fully featured RESTful endpoints for authentication, password reset and CRUD operations on the user entity -* Session handling -* JWT Based authentication -* Application configuration via config file (yaml) -* RBAC (role-based access control) -* Structured (error) logging -* Great performance -* Partial update (PATCH) handling using reflection -* Request marshaling and data validation -* API Docs using SwaggerUI -* Mocking using stdlib -* Full test coverage -* Containerized DB query tests - -The following dependencies are used in this project (generated using [Glice](https://github.com/ribice/glice)): - -```bash -|-------------------------------------|--------------------------------------------|--------------| -| DEPENDENCY | REPOURL | LICENSE | -|-------------------------------------|--------------------------------------------|--------------| -| github.com/labstack/echo | https://github.com/labstack/echo | MIT | -| github.com/go-pg/pg | https://github.com/go-pg/pg | bsd-2-clause | -| github.com/dgrijalva/jwt-go | https://github.com/dgrijalva/jwt-go | MIT | -| github.com/rs/xid | https://github.com/rs/xid | MIT | -| golang.org/x/crypto/bcrypt | https://github.com/golang/crypto | | -| gopkg.in/yaml.v2 | https://github.com/go-yaml/yaml | | -| gopkg.in/go-playground/validator.v8 | https://github.com/go-playground/validator | MIT | -| github.com/lib/pq | https://github.com/lib/pq | Other | -| github.com/fortytw2/dockertest | https://github.com/fortytw2/dockertest | MIT | -| github.com/stretchr/testify | https://github.com/stretchr/testify | Other | -|-------------------------------------|--------------------------------------------|--------------| -``` - -1. Echo - HTTP 'framework'. -2. Go-Pg - PostgreSQL ORM -3. JWT-Go - JWT Authentication -4. XID - Generating refresh tokens -5. Bcrypt - Password hashing -6. Yaml - Unmarshalling YAML config file -7. Validator - Request validation. -8. lib/pq - Postgres driver -9. DockerTest - Testing database queries -10. Testify/Assert - Asserting test results - -Most of these can easily be replaced with your own choices since their usage is abstracted and localized. - -## Getting started - -Using Gorsk requires having Go 1.8 or above. Once you downloaded Gorsk (either using Git or go get) you need to configure the following: - -1. To use Gorsk as a starting point of a real project whose package name is something like `github.com/author/project`, move the directory `$GOPATH/github.com/ribice/gorsk` to `$GOPATH/github.com/author/project` and do a global replacement of the string `github.com/ribice/gorsk` with `github.com/author/project`. - -2. Change the configuration file according to your needs, or create a new one. - -3. Set the ("ENVIRONMENT_NAME") environment variable, either using terminal or os.Setenv("ENVIRONMENT_NAME","dev"). - -4. In cmd/migration/main.go set up psn variable and then run it (go run main.go). It will create all tables, and necessery data, with a new account username/password admin/admin. - -5. Run the app using: - -```bash -go run cmd/api/main.go -``` - -The application runs as an HTTP server at port 8080. It provides the following RESTful endpoints: - -* `POST /login`: accepts username/passwords and returns jwt token and refresh token -* `GET /refresh/:token`: refreshes sessions and returns jwt token -* `GET /me`: returns info about currently logged in user -* `GET /swaggerui/`: launches swaggerui in browser -* `GET /v1/users`: returns list of users -* `GET /v1/users/:id`: returns single user -* `POST /v1/users`: creates a new user -* `PATCH /v1/users/:id/password`: changes password for a user -* `DELETE /v1/users/:id`: deletes a user - -You can log in as admin to the application by sending a post request to localhost:8080/login with username `admin` and password `admin` in JSON body. - -### Implementing CRUD of another table - -Let's say you have a table named 'cars' that handles employee's cars. To implement CRUD on this table you need: - -1. In project's root location create a new file named `car.go`. Inside put your entity (struct), and methods on the struct if you need them. Create interfaces for all platforms you have, for example, database, indexing, reporting etc. An example for this is UserDB interface in user.go - -2. Create a new folder in root named car, and inside create a file/service named car.go and tests for it car_test.go (`car/car.go` and `car/car_test.go`). You can test your code without writing a single query by mocking the database logic inside /mock/mockdb folder. If you have complex queries interfering with other entities, you can create in this folder other files such as car_users.go or car_templates.go for example. - -3. Database access code can be found under platform/postgres folder. (`platform/postgres/car.go` and `platform/postgres/car.go`) - -4. In `cmd/api/service` create a new file named `car.go`. This is where your handlers are located. To handle the request data, create a new file inside `cmd/api/request` that will handle validation and request marshaling. Under the same location create car_test.go to test your API. - -5. In `cmd/api/swagger` create car.go containing small structs to generate swagger API docs. - -6. In `cmd/api/main.go` wire up all the logic, by creating a CarDB instance, passing it to car service and then the service to the handler. - -### Implementing other platforms - -Similarly to implementing APIs relying only on a database, you can implement other platforms by: - -1. In the model package, in car.go add struct that corresponds to the platform, for example, CarElastic, or CarReporting. Create an interface that will handle communication to the platform, for example, CarIndexer interface. - -2. Rest of the procedure is same, except that in `/platform` you would create a new folder for your platform, for example, `elastic`. - -3. Once the new platform logic is implemented, create an instance of it in main.go (for example `elastic.client`) and pass it as an argument to car service (`car/car.go`). - -### Running database queries in transaction - -To use a transaction, before interacting with db create a new transaction: - -```go -err := s.db.RunInTransaction(func (tx *pg.Tx) error{ - // Application service here -}) -```` - -Then handle the error accordingly. - -## Project Structure - -The project structure is explained in details in [THIS](https://medium.com/@benbjohnson/standard-package-layout-7cdbc8391fc1) blog post, with some small changes taken from [Package Oriented Design](https://www.ardanlabs.com/blog/2017/02/package-oriented-design.html). and GoDD. Primarily the package design is architectured by [tonto](https://github.com/tonto) with some small changes done by me. - -1. Root package contains things not related to code directly, e.g. docker-compose, CI/CD, readme, bash scripts etc. It should also contain vendor folder, Gopkg.toml and Gopkg.lock if dep is being used. - -2. Main package ties together dependencies. Located in cmd/api/main.go, the main package instantiates routing, all configurations, connections etc and injects them as dependencies to services. Gorsk is structured as a monolith application but can be easily restructured to contain multiple microservices. An application may produce multiple binaries, therefore Gorsk uses the Go convention of placing main package as a subdirectory of the cmd package. As an example, scheduler application's binary would be located under cmd/cron. - -3. Rest of the code is located under /internal. Internal root contains domain types, e.g. User, Car, Company. This package only contains simple data types like User struct for holding user data or a UserService interface for fetching or saving user data. - -4. Domain packages are located under the internal directory, in folders named after the domain. For example, car package is located in internal/car. All application/business logic is found here, and it connects to other 'platforms' such as a database, reporting, indexing, etc. - -5. Platform folder contains various packages that provide support for things like databases, authentication or even marshaling. Most of the packages located under platform are decoupled by using interfaces. Every platform has its own package, for example, postgres, elastic, redis, memcache etc. - -6. Mock package is shared across the entire application. Specific platforms have subpackages such as mockdb, mockindex etc. Since most of the dependencies are injected, it is trivial to mock them and pass the mock service as an argument. - -7. Service package contains HTTP handlers. They only receive the requests, marshal and validate them using a helper package (request) and pass it to the corresponding services. - -8. MW Package contains middleware related implementation, such as JWT authentication, CORS, perhaps request/error logging, security etc. - -9. Config package contains application configurations, as well as the implementation to read the config files. - -10. Swagger is a helper package containing go-swagger annotations for SwaggerUI generation. - -## License - -gorsk is licensed under the MIT license. Check the [LICENSE](LICENSE.md) file for details. - -## Author - -[Emir Ribic](https://ribice.ba) +# GORSK - GO(lang) Restful Starter Kit + +[![Build Status](https://travis-ci.org/ribice/gorsk.svg?branch=master)](https://travis-ci.org/ribice/gorsk) +[![codecov](https://codecov.io/gh/ribice/gorsk/branch/master/graph/badge.svg)](https://codecov.io/gh/ribice/gorsk) +[![Go Report Card](https://goreportcard.com/badge/github.com/ribice/gorsk)](https://goreportcard.com/report/github.com/ribice/gorsk) +[![Maintainability](https://api.codeclimate.com/v1/badges/c3cb09dbc0bc43186464/maintainability)](https://codeclimate.com/github/ribice/gorsk/maintainability) + +**[Gorsk V2 is released - read more about it](https://www.ribice.ba/refactoring-gorsk/)** + +Gorsk is a Golang starter kit for developing RESTful services. It is designed to help you kickstart your project, skipping the 'setting-up part' and jumping straight to writing business logic. + +Previously Gorsk was built using [Gin](https://github.com/gin-gonic/gin). Gorsk using Gin is available [HERE](https://github.com/ribice/gorsk-gin). + +Gorsk follows SOLID principles, with package design being inspired by several package designs, including Ben Johnson's [Standard Package Layout](https://medium.com/@benbjohnson/standard-package-layout-7cdbc8391fc1), [Go Standard Package Layout](https://github.com/golang-standards/project-layout) with my own ideas applied to both. The idea for building this project and its readme structure was inspired by [this](https://github.com/qiangxue/golang-restful-starter-kit). + +This starter kit currently provides: + +* Fully featured RESTful endpoints for authentication, password reset and CRUD operations on the user entity +* Session handling +* JWT Based authentication +* Application configuration via config file (yaml) +* RBAC (role-based access control) +* Structured (error) logging +* Great performance +* Partial update (PATCH) handling using reflection +* Request marshaling and data validation +* API Docs using SwaggerUI +* Mocking using stdlib +* Full test coverage +* Containerized DB query tests + +The following dependencies are used in this project (generated using [Glice](https://github.com/ribice/glice)): + +```bash +|-------------------------------------|--------------------------------------------|--------------| +| DEPENDENCY | REPOURL | LICENSE | +|-------------------------------------|--------------------------------------------|--------------| +| github.com/labstack/echo | https://github.com/labstack/echo | MIT | +| github.com/go-pg/pg | https://github.com/go-pg/pg | bsd-2-clause | +| github.com/dgrijalva/jwt-go | https://github.com/dgrijalva/jwt-go | MIT | +| golang.org/x/crypto/bcrypt | https://github.com/golang/crypto | | +| gopkg.in/yaml.v2 | https://github.com/go-yaml/yaml | | +| gopkg.in/go-playground/validator.v8 | https://github.com/go-playground/validator | MIT | +| github.com/lib/pq | https://github.com/lib/pq | Other | +| github.com/nbutton23/zxcvbn-go | https://github.com/nbutton23/zxcvbn-go | MIT | +| github.com/fortytw2/dockertest | https://github.com/fortytw2/dockertest | MIT | +| github.com/stretchr/testify | https://github.com/stretchr/testify | Other | +|-------------------------------------|--------------------------------------------|--------------| +``` + +# DODATI ZXCVBN + +1. Echo - HTTP 'framework'. +2. Go-Pg - PostgreSQL ORM +3. JWT-Go - JWT Authentication +4. zxcvbn-go - Password strength checker +5. Bcrypt - Password hashing +6. Yaml - Unmarshalling YAML config file +7. Validator - Request validation. +8. lib/pq - Postgres driver +9. DockerTest - Testing database queries +10. Testify/Assert - Asserting test results + +Most of these can easily be replaced with your own choices since their usage is abstracted and localized. + +## Getting started + +Using Gorsk requires having Go 1.7 or above. Once you downloaded Gorsk (either using Git or go get) you need to configure the following: + +1. To use Gorsk as a starting point of a real project whose package name is something like `github.com/author/project`, move the directory `$GOPATH/github.com/ribice/gorsk` to `$GOPATH/github.com/author/project` and do a global replacement of the string `github.com/ribice/gorsk` with `github.com/author/project`. + +2. Rename the gorsk package inside `pkg/utl/model` with your own project name, then using search & replace do a global replacement of `.gorsk` with your project name. + +3. Change the configuration file according to your needs, or create a new one. + +4. Set the ("ENVIRONMENT_NAME") environment variable, either using terminal or os.Setenv("ENVIRONMENT_NAME","dev"). + +5. In cmd/migration/main.go set up psn variable and then run it (go run main.go). It will create all tables, and necessery data, with a new account username/password admin/admin. + +6. Run the app using: + +```bash +go run cmd/api/main.go +``` + +The application runs as an HTTP server at port 8080. It provides the following RESTful endpoints: + +* `POST /login`: accepts username/passwords and returns jwt token and refresh token +* `GET /refresh/:token`: refreshes sessions and returns jwt token +* `GET /me`: returns info about currently logged in user +* `GET /swaggerui/`: launches swaggerui in browser +* `GET /v1/users`: returns list of users +* `GET /v1/users/:id`: returns single user +* `POST /v1/users`: creates a new user +* `PATCH /v1/password/:id`: changes password for a user +* `DELETE /v1/users/:id`: deletes a user + +You can log in as admin to the application by sending a post request to localhost:8080/login with username `admin` and password `admin` in JSON body. + +### Implementing CRUD of another table + +Let's say you have a table named 'cars' that handles employee's cars. To implement CRUD on this table you need: + +1. Inside `pkg/utl/model` create a new file named `car.go`. Inside put your entity (struct), and methods on the struct if you need them. + +2. Create a new `car` folder in the (micro)service where your service will be located, most probably inside `api`. Inside create a file/service named car.go and test file for it (`car/car.go` and `car/car_test.go`). You can test your code without writing a single query by mocking the database logic inside /mock/mockdb folder. If you have complex queries interfering with other entities, you can create in this folder other files such as car_users.go or car_templates.go for example. + +3. Inside car folder, create two folders named `platform` and `transport`. + +4. Code for interacting with a platform like database (postgresql) should be placed under `car/platform/pgsql`. (`pkg/api/car/platform/pgsql/car.go`) + +5. In `pkg/api/car/transport` create a new file named `http.go`. This is where your handlers are located. Under the same location create http_test.go to test your API. + +6. In `pkg/api/api.go` wire up all the logic, by instantiating car service and then pass it to the transport package. + +### Implementing other platforms + +Similarly to implementing APIs relying only on a database, you can implement other platforms by: + +1. In the service package, in car.go add interface that corresponds to the platform, for example, Indexer or Reporter. + +2. Rest of the procedure is same, except that in `/platform` you would create a new folder for your platform, for example, `elastic`. + +3. Once the new platform logic is implemented, create an instance of it in main.go (for example `elastic.Client`) and pass it as an argument to car service (`pkg/api/car/car.go`). + +### Running database queries in transaction + +To use a transaction, before interacting with db create a new transaction: + +```go +err := s.db.RunInTransaction(func (tx *pg.Tx) error{ + // Application service here +}) +```` + +Instead of passing database client as `s.db` , inside this function pass it as `tx`. Handle the error accordingly. + +## Project Structure + +1. Root directory contains things not related to code directly, e.g. docker-compose, CI/CD, readme, bash scripts etc. It should also contain vendor folder, Gopkg.toml and Gopkg.lock if dep is being used. + +2. Cmd package contains code for starting applications (main packages). The directory name for each application should match the name of the executable you want to have. Gorsk is structured as a monolith application but can be easily restructured to contain multiple microservices. An application may produce multiple binaries, therefore Gorsk uses the Go convention of placing main package as a subdirectory of the cmd package. As an example, scheduler application's binary would be located under cmd/cron. It also loads the necessery configuration and passes it to the service initializers. + +3. Rest of the code is located under /pkg. The pkg directory contains `utl` and 'microservice' directories. + +4. Microservice directories, like api (naming corresponds to `cmd/` folder naming) contains multiple folders for each domain it interacts with, for example: user, car, appointment etc. + +5. Domain directories, like user, contain all application/business logic and two additional directories: platform and transport. + +6. Platform folder contains various packages that provide support for things like databases, authentication or even marshaling. Most of the packages located under platform are decoupled by using interfaces. Every platform has its own package, for example, postgres, elastic, redis, memcache etc. + +7. Transport package contains HTTP handlers. The package receives the requests, marshals, validates then passes it to the corresponding service. + +8. Utl directory contains helper packages and models. Packages such as mock, middleware, configuration, server are located here. + +## License + +gorsk is licensed under the MIT license. Check the [LICENSE](LICENSE.md) file for details. + +## Author + +[Emir Ribic](https://ribice.ba) diff --git a/cmd/api/swaggerui/favicon-16x16.png b/assets/swaggerui/favicon-16x16.png similarity index 100% rename from cmd/api/swaggerui/favicon-16x16.png rename to assets/swaggerui/favicon-16x16.png diff --git a/cmd/api/swaggerui/favicon-32x32.png b/assets/swaggerui/favicon-32x32.png similarity index 100% rename from cmd/api/swaggerui/favicon-32x32.png rename to assets/swaggerui/favicon-32x32.png diff --git a/cmd/api/swaggerui/index.html b/assets/swaggerui/index.html similarity index 100% rename from cmd/api/swaggerui/index.html rename to assets/swaggerui/index.html diff --git a/cmd/api/swaggerui/oauth2-redirect.html b/assets/swaggerui/oauth2-redirect.html similarity index 100% rename from cmd/api/swaggerui/oauth2-redirect.html rename to assets/swaggerui/oauth2-redirect.html diff --git a/cmd/api/swaggerui/swagger-ui-bundle.js b/assets/swaggerui/swagger-ui-bundle.js similarity index 100% rename from cmd/api/swaggerui/swagger-ui-bundle.js rename to assets/swaggerui/swagger-ui-bundle.js diff --git a/cmd/api/swaggerui/swagger-ui-bundle.js.map b/assets/swaggerui/swagger-ui-bundle.js.map similarity index 100% rename from cmd/api/swaggerui/swagger-ui-bundle.js.map rename to assets/swaggerui/swagger-ui-bundle.js.map diff --git a/cmd/api/swaggerui/swagger-ui-standalone-preset.js b/assets/swaggerui/swagger-ui-standalone-preset.js similarity index 100% rename from cmd/api/swaggerui/swagger-ui-standalone-preset.js rename to assets/swaggerui/swagger-ui-standalone-preset.js diff --git a/cmd/api/swaggerui/swagger-ui-standalone-preset.js.map b/assets/swaggerui/swagger-ui-standalone-preset.js.map similarity index 100% rename from cmd/api/swaggerui/swagger-ui-standalone-preset.js.map rename to assets/swaggerui/swagger-ui-standalone-preset.js.map diff --git a/cmd/api/swaggerui/swagger-ui.css b/assets/swaggerui/swagger-ui.css similarity index 100% rename from cmd/api/swaggerui/swagger-ui.css rename to assets/swaggerui/swagger-ui.css diff --git a/cmd/api/swaggerui/swagger-ui.css.map b/assets/swaggerui/swagger-ui.css.map similarity index 100% rename from cmd/api/swaggerui/swagger-ui.css.map rename to assets/swaggerui/swagger-ui.css.map diff --git a/cmd/api/swaggerui/swagger-ui.js b/assets/swaggerui/swagger-ui.js similarity index 100% rename from cmd/api/swaggerui/swagger-ui.js rename to assets/swaggerui/swagger-ui.js diff --git a/cmd/api/swaggerui/swagger-ui.js.map b/assets/swaggerui/swagger-ui.js.map similarity index 100% rename from cmd/api/swaggerui/swagger-ui.js.map rename to assets/swaggerui/swagger-ui.js.map diff --git a/cmd/api/swaggerui/swagger.json b/assets/swaggerui/swagger.json similarity index 100% rename from cmd/api/swaggerui/swagger.json rename to assets/swaggerui/swagger.json diff --git a/cmd/api/conf.local.yaml b/cmd/api/conf.local.yaml new file mode 100644 index 0000000..c988268 --- /dev/null +++ b/cmd/api/conf.local.yaml @@ -0,0 +1,21 @@ +database: + log_queries: true + timeout_seconds: 5 + psn: postgres://biadpozi:3_Czbl7jSjkUEWk--VP8QXMke-mFnczq@horton.elephantsql.com:5432/biadpozi + +server: + port: :8080 + debug: true + read_timeout_seconds: 10 + write_timeout_seconds: 5 + +jwt: + secret: jwtrealm # Change this value + duration_minutes: 15 + refresh_duration_minutes: 15 + max_refresh_minutes: 1440 + signing_algorithm: HS256 + +application: + min_password_strength: 1 + swagger_ui_path: assets/swaggerui \ No newline at end of file diff --git a/cmd/api/config/config.go b/cmd/api/config/config.go deleted file mode 100644 index a3c5da1..0000000 --- a/cmd/api/config/config.go +++ /dev/null @@ -1,58 +0,0 @@ -package config - -import ( - "fmt" - "io/ioutil" - "path/filepath" - "runtime" - - yaml "gopkg.in/yaml.v2" -) - -// Load returns Configuration struct -func Load(env string) (*Configuration, error) { - _, filePath, _, _ := runtime.Caller(0) - configFile := filePath[:len(filePath)-9] - bytes, err := ioutil.ReadFile(configFile + - "files" + string(filepath.Separator) + "config." + env + ".yaml") - if err != nil { - return nil, fmt.Errorf("error reading config file, %s", err) - } - var cfg = new(Configuration) - if err := yaml.Unmarshal(bytes, cfg); err != nil { - return nil, fmt.Errorf("unable to decode into struct, %v", err) - } - return cfg, nil -} - -// Configuration holds data necessery for configuring application -type Configuration struct { - Server *Server - DB *Database - JWT *JWT -} - -// Database holds data necessery for database configuration -type Database struct { - PSN string - Log bool - CreateSchema bool - Timeout int -} - -// Server holds data necessery for server configuration -type Server struct { - Port string - Debug bool - ReadTimeout int - WriteTimeout int -} - -// JWT holds data necessery for JWT configuration -type JWT struct { - Secret string - Duration int - RefreshDuration int - MaxRefresh int - SigningAlgorithm string -} diff --git a/cmd/api/config/files/config.dev.yaml b/cmd/api/config/files/config.dev.yaml deleted file mode 100644 index 70f0c51..0000000 --- a/cmd/api/config/files/config.dev.yaml +++ /dev/null @@ -1,16 +0,0 @@ -db: - log: true - timeout: 5 # Query timeout in seconds - createschema: false - psn: postgres://biadpozi:3_Czbl7jSjkUEWk--VP8QXMke-mFnczq@horton.elephantsql.com:5432/biadpozi - -server: - port: ":8080" - debug: true - readtimeout: 10 # Request read timeout in minutes - writetimeout: 5 # Response write timeout in minutes - -jwt: - realm: jwtrealm # Change this value - duration: 15 - signingalgorithm: HS256 \ No newline at end of file diff --git a/cmd/api/config/files/config.testdata.yaml b/cmd/api/config/files/config.testdata.yaml deleted file mode 100644 index ae0e5df..0000000 --- a/cmd/api/config/files/config.testdata.yaml +++ /dev/null @@ -1,10 +0,0 @@ -db: - log: true - createschema: false - -server: - port: ":8080" - debug: true - -jwt: - duration: 10800 \ No newline at end of file diff --git a/cmd/api/main.go b/cmd/api/main.go index b951e67..bcb455c 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -1,93 +1,22 @@ -// Copyright 2017 Emir Ribic. All rights reserved. -// Use of this source code is governed by an MIT-style -// license that can be found in the LICENSE file. - -// GORSK - Go(lang) restful starter kit -// -// API Docs for GORSK v1 -// -// Terms Of Service: N/A -// Schemes: http -// Version: 1.0.0 -// License: MIT http://opensource.org/licenses/MIT -// Contact: Emir Ribic https://ribice.ba -// Host: localhost:8080 -// -// Consumes: -// - application/json -// -// Produces: -// - application/json -// -// Security: -// - bearer: [] -// -// SecurityDefinitions: -// bearer: -// type: apiKey -// name: Authorization -// in: header -// -// swagger:meta package main import ( - "github.com/labstack/echo" - "github.com/ribice/gorsk/internal/platform/postgres" + "flag" + + "github.com/ribice/gorsk/pkg/api" - "github.com/go-pg/pg" - "github.com/ribice/gorsk/cmd/api/config" - "github.com/ribice/gorsk/cmd/api/mw" - "github.com/ribice/gorsk/cmd/api/server" - "github.com/ribice/gorsk/cmd/api/service" - _ "github.com/ribice/gorsk/cmd/api/swagger" - "github.com/ribice/gorsk/internal/account" - "github.com/ribice/gorsk/internal/auth" - "github.com/ribice/gorsk/internal/rbac" - "github.com/ribice/gorsk/internal/user" + "github.com/ribice/gorsk/pkg/utl/config" ) func main() { - cfg, err := config.Load("dev") - checkErr(err) - - e := server.New() + cfgPath := flag.String("p", "./cmd/api/conf.local.yaml", "Path to config file") + flag.Parse() - db, err := pgsql.New(cfg.DB) + cfg, err := config.Load(*cfgPath) checkErr(err) - addV1Services(cfg, e, db) - - server.Start(e, cfg.Server) -} - -func addV1Services(cfg *config.Configuration, e *echo.Echo, db *pg.DB) { - - // Initialize DB interfaces - - userDB := pgsql.NewUserDB(e.Logger) - accDB := pgsql.NewAccountDB(e.Logger) - - // Initialize services - - jwt := mw.NewJWT(cfg.JWT) - authSvc := auth.New(db, userDB, jwt) - service.NewAuth(authSvc, e, jwt.MWFunc()) - - e.Static("/swaggerui", "cmd/api/swaggerui") - - rbacSvc := rbac.New(userDB) - - v1Router := e.Group("/v1") - - v1Router.Use(jwt.MWFunc()) - - // Workaround for Echo's issue with routing. - // v1Router should be passed to service normally, and then the group name created there - uR := v1Router.Group("/users") - service.NewAccount(account.New(db, accDB, userDB, rbacSvc), uR) - service.NewUser(user.New(db, userDB, rbacSvc, authSvc), uR) + checkErr(api.Start(cfg)) } func checkErr(err error) { diff --git a/cmd/api/request/account.go b/cmd/api/request/account.go deleted file mode 100644 index 5ae3af6..0000000 --- a/cmd/api/request/account.go +++ /dev/null @@ -1,62 +0,0 @@ -package request - -import ( - "net/http" - - "github.com/labstack/echo" - "github.com/ribice/gorsk/internal" -) - -// Register contains registration request -type Register struct { - FirstName string `json:"first_name" validate:"required"` - LastName string `json:"last_name" validate:"required"` - Username string `json:"username" validate:"required,min=3,alphanum"` - Password string `json:"password" validate:"required,min=8"` - PasswordConfirm string `json:"password_confirm" validate:"required"` - Email string `json:"email" validate:"required,email"` - - CompanyID int `json:"company_id" validate:"required"` - LocationID int `json:"location_id" validate:"required"` - RoleID model.AccessRole `json:"role_id" validate:"required"` -} - -// AccountCreate validates account creation request -func AccountCreate(c echo.Context) (*Register, error) { - r := new(Register) - if err := c.Bind(r); err != nil { - return nil, err - } - if r.Password != r.PasswordConfirm { - return nil, echo.NewHTTPError(http.StatusBadRequest, "passwords do not match") - } - if r.RoleID < model.SuperAdminRole || r.RoleID > model.UserRole { - return nil, echo.NewHTTPError(http.StatusBadRequest) - } - return r, nil -} - -// Password contains password change request -type Password struct { - ID int `json:"-"` - OldPassword string `json:"old_password" validate:"required,min=8"` - NewPassword string `json:"new_password" validate:"required,min=8"` - NewPasswordConfirm string `json:"new_password_confirm" validate:"required"` -} - -// PasswordChange validates password change request -func PasswordChange(c echo.Context) (*Password, error) { - id, err := ID(c) - if err != nil { - return nil, err - } - p := new(Password) - if err := c.Bind(p); err != nil { - return nil, err - } - if p.NewPassword != p.NewPasswordConfirm { - return nil, echo.NewHTTPError(http.StatusBadRequest, "passwords do not match") - } - p.ID = id - return p, nil -} diff --git a/cmd/api/request/account_test.go b/cmd/api/request/account_test.go deleted file mode 100644 index 8e86835..0000000 --- a/cmd/api/request/account_test.go +++ /dev/null @@ -1,115 +0,0 @@ -package request_test - -import ( - "bytes" - "net/http" - "net/http/httptest" - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/ribice/gorsk/cmd/api/request" - "github.com/ribice/gorsk/internal/mock" -) - -func TestAccountCreate(t *testing.T) { - cases := []struct { - name string - req string - wantErr bool - wantData *request.Register - }{ - { - name: "Fail on validating JSON", - wantErr: true, - req: `{"first_name":"John","last_name":"Doe","username":"juzernejm","password":"hunter123","password_confirm":"hunter1234","email":"johndoe@gmail.com","company_id":1,"location_id":2}`, - }, - { - name: "Fail on password match", - wantErr: true, - req: `{"first_name":"John","last_name":"Doe","username":"juzernejm","password":"hunter123","password_confirm":"hunter1234","email":"johndoe@gmail.com","company_id":1,"location_id":2,"role_id":3}`, - }, - { - name: "Fail on non-existent role_id", - wantErr: true, - req: `{"first_name":"John","last_name":"Doe","username":"juzernejm","password":"hunter123","password_confirm":"hunter123","email":"johndoe@gmail.com","company_id":1,"location_id":2,"role_id":9}`, - }, - { - name: "Success", - req: `{"first_name":"John","last_name":"Doe","username":"juzernejm","password":"hunter123","password_confirm":"hunter123","email":"johndoe@gmail.com","company_id":1,"location_id":2,"role_id":2}`, - wantData: &request.Register{ - FirstName: "John", - LastName: "Doe", - Username: "juzernejm", - Password: "hunter123", - PasswordConfirm: "hunter123", - Email: "johndoe@gmail.com", - CompanyID: 1, - LocationID: 2, - RoleID: 2, - }, - }, - } - for _, tt := range cases { - t.Run(tt.name, func(t *testing.T) { - w := httptest.NewRecorder() - req, _ := http.NewRequest("POST", "", bytes.NewBufferString(tt.req)) - c := mock.EchoCtx(req, w) - reg, err := request.AccountCreate(c) - assert.Equal(t, tt.wantData, reg) - assert.Equal(t, tt.wantErr, err != nil) - }) - } -} - -func TestPasswordChange(t *testing.T) { - cases := []struct { - name string - id string - req string - wantErr bool - wantData *request.Password - }{ - { - name: "Fail on ID param", - wantErr: true, - id: "NaN", - }, - { - name: "Fail on binding JSON", - wantErr: true, - id: "1", - req: `{"new_password":"new_password","old_password":"my_old_password"}`, - }, - { - name: "Not matching passwords", - wantErr: true, - id: "1", - req: `{"new_password":"new_password","old_password":"my_old_password", "new_password_confirm":"new_password_cf"}`, - }, - { - name: "Success", - id: "10", - req: `{"new_password":"newpassw","old_password":"oldpassw", "new_password_confirm":"newpassw"}`, - wantData: &request.Password{ - ID: 10, - NewPassword: "newpassw", - NewPasswordConfirm: "newpassw", - OldPassword: "oldpassw", - }, - }, - } - - for _, tt := range cases { - t.Run(tt.name, func(t *testing.T) { - w := httptest.NewRecorder() - req, _ := http.NewRequest("POST", "/", bytes.NewBufferString(tt.req)) - c := mock.EchoCtx(req, w) - c.SetParamNames("id") - c.SetParamValues(tt.id) - pw, err := request.PasswordChange(c) - assert.Equal(t, tt.wantData, pw) - assert.Equal(t, tt.wantErr, err != nil) - }) - } -} diff --git a/cmd/api/request/auth.go b/cmd/api/request/auth.go deleted file mode 100644 index dda3869..0000000 --- a/cmd/api/request/auth.go +++ /dev/null @@ -1,21 +0,0 @@ -package request - -import ( - "github.com/labstack/echo" -) - -// Credentials contains login request -type Credentials struct { - Username string `json:"username" validate:"required"` - Password string `json:"password" validate:"required"` -} - -// Login validates login request -func Login(c echo.Context) (*Credentials, error) { - cred := new(Credentials) - if err := c.Bind(cred); err != nil { - return nil, err - - } - return cred, nil -} diff --git a/cmd/api/request/auth_test.go b/cmd/api/request/auth_test.go deleted file mode 100644 index 540f679..0000000 --- a/cmd/api/request/auth_test.go +++ /dev/null @@ -1,46 +0,0 @@ -package request_test - -import ( - "bytes" - "net/http" - "net/http/httptest" - "testing" - - "github.com/ribice/gorsk/cmd/api/request" - "github.com/ribice/gorsk/internal/mock" - "github.com/stretchr/testify/assert" -) - -func TestLogin(t *testing.T) { - cases := []struct { - name string - req string - wantErr bool - wantData *request.Credentials - }{ - { - name: "Fail on binding JSON", - wantErr: true, - req: `{"username":"juzernejm"}`, - }, - { - name: "Success", - req: `{"username":"juzernejm","password":"hunter123"}`, - wantData: &request.Credentials{ - Username: "juzernejm", - Password: "hunter123", - }, - }, - } - - for _, tt := range cases { - t.Run(tt.name, func(t *testing.T) { - w := httptest.NewRecorder() - req, _ := http.NewRequest("POST", "", bytes.NewBufferString(tt.req)) - c := mock.EchoCtx(req, w) - resp, err := request.Login(c) - assert.Equal(t, tt.wantData, resp) - assert.Equal(t, tt.wantErr, err != nil) - }) - } -} diff --git a/cmd/api/request/request.go b/cmd/api/request/request.go deleted file mode 100644 index 432d3ec..0000000 --- a/cmd/api/request/request.go +++ /dev/null @@ -1,46 +0,0 @@ -package request - -import ( - "net/http" - "strconv" - - "github.com/labstack/echo" -) - -const ( - defaultLimit = 100 - maxLimit = 1000 -) - -// Pagination contains pagination request -type Pagination struct { - Limit int `query:"limit"` - Page int `query:"page" validate:"min=0"` - Offset int `json:"-"` -} - -// Paginate validates pagination requests -func Paginate(c echo.Context) (*Pagination, error) { - p := new(Pagination) - if err := c.Bind(p); err != nil { - return nil, err - } - if p.Limit < 1 { - p.Limit = defaultLimit - } - if p.Limit > 1000 { - p.Limit = maxLimit - } - p.Offset = p.Limit * p.Page - return p, nil -} - -// ID returns id url parameter. -// In case of conversion error to int, StatusBadRequest will be returned as err -func ID(c echo.Context) (int, error) { - id, err := strconv.Atoi(c.Param("id")) - if err != nil { - return 0, echo.NewHTTPError(http.StatusBadRequest) - } - return id, nil -} diff --git a/cmd/api/request/request_test.go b/cmd/api/request/request_test.go deleted file mode 100644 index 026f9fb..0000000 --- a/cmd/api/request/request_test.go +++ /dev/null @@ -1,100 +0,0 @@ -package request_test - -import ( - "net/http" - "net/http/httptest" - "testing" - - "github.com/labstack/echo" - "github.com/ribice/gorsk/cmd/api/request" - "github.com/ribice/gorsk/internal/mock" - "github.com/stretchr/testify/assert" -) - -func TestPaginate(t *testing.T) { - cases := []struct { - name string - req string - wantErr bool - wantData *request.Pagination - }{ - { - name: "Fail on binding JSON", - wantErr: true, - req: `/?limit=50&page=-1`, - }, - { - name: "Test default limit", - req: `/?limit=0`, - wantData: &request.Pagination{ - Limit: 100, - }, - }, - { - name: "Test max limit", - req: `/?limit=2222&page=2`, - wantData: &request.Pagination{ - Limit: 1000, - Offset: 2000, - Page: 2, - }, - }, - { - name: "Test default", - req: `/?limit=200&page=2`, - wantData: &request.Pagination{ - Limit: 200, - Offset: 400, - Page: 2, - }, - }, - } - for _, tt := range cases { - t.Run(tt.name, func(t *testing.T) { - w := httptest.NewRecorder() - req, err := http.NewRequest("GET", tt.req, nil) - if err != nil { - t.Error("Could not create http request") - } - c := mock.EchoCtx(req, w) - resp, err := request.Paginate(c) - assert.Equal(t, tt.wantData, resp) - assert.Equal(t, tt.wantErr, err != nil) - }) - } -} -func TestID(t *testing.T) { - cases := []struct { - name string - id string - wantErr bool - wantData int - }{ - { - name: "EmptyID", - wantErr: true, - }, - { - name: "ID Not a Number", - id: "NaN", - wantErr: true, - }, - { - name: "Success", - wantData: 1, - id: "1", - }, - } - for _, tt := range cases { - t.Run(tt.name, func(t *testing.T) { - w := httptest.NewRecorder() - req, _ := http.NewRequest(echo.GET, "/", nil) - c := mock.EchoCtx(req, w) - c.SetParamNames("id") - c.SetParamValues(tt.id) - resp, err := request.ID(c) - assert.Equal(t, tt.wantData, resp) - assert.Equal(t, tt.wantErr, err != nil) - }) - } -} diff --git a/cmd/api/request/user.go b/cmd/api/request/user.go deleted file mode 100644 index cb5f6af..0000000 --- a/cmd/api/request/user.go +++ /dev/null @@ -1,29 +0,0 @@ -package request - -import ( - "github.com/labstack/echo" -) - -// UpdateUser contains user update data from json request -type UpdateUser struct { - ID int `json:"-"` - FirstName *string `json:"first_name,omitempty" validate:"omitempty,min=2"` - LastName *string `json:"last_name,omitempty" validate:"omitempty,min=2"` - Mobile *string `json:"mobile,omitempty"` - Phone *string `json:"phone,omitempty"` - Address *string `json:"address,omitempty"` -} - -// UserUpdate validates user update request -func UserUpdate(c echo.Context) (*UpdateUser, error) { - id, err := ID(c) - if err != nil { - return nil, err - } - u := new(UpdateUser) - if err := c.Bind(u); err != nil { - return nil, err - } - u.ID = id - return u, nil -} diff --git a/cmd/api/request/user_test.go b/cmd/api/request/user_test.go deleted file mode 100644 index b28669d..0000000 --- a/cmd/api/request/user_test.go +++ /dev/null @@ -1,61 +0,0 @@ -package request_test - -import ( - "bytes" - "net/http" - "net/http/httptest" - "testing" - - "github.com/ribice/gorsk/internal/mock" - "github.com/stretchr/testify/assert" - - "github.com/ribice/gorsk/cmd/api/request" -) - -func TestUserUpdate(t *testing.T) { - cases := []struct { - name string - id string - req string - wantErr bool - wantData *request.UpdateUser - }{ - { - name: "Fail on ID param", - wantErr: true, - id: "NaN", - req: `{}`, - }, - { - name: "Fail on binding JSON", - wantErr: true, - id: "1", - req: `{"first_name":"j","last_name":"okocha"}`, - }, - { - name: "Success", - id: "1", - req: `{"first_name":"jj","last_name":"okocha","mobile":"123456","phone":"321321","address":"home"}`, - wantData: &request.UpdateUser{ - ID: 1, - FirstName: mock.Str2Ptr("jj"), - LastName: mock.Str2Ptr("okocha"), - Mobile: mock.Str2Ptr("123456"), - Phone: mock.Str2Ptr("321321"), - Address: mock.Str2Ptr("home"), - }, - }, - } - for _, tt := range cases { - t.Run(tt.name, func(t *testing.T) { - w := httptest.NewRecorder() - req, _ := http.NewRequest("PATCH", "/", bytes.NewBufferString(tt.req)) - c := mock.EchoCtx(req, w) - c.SetParamNames("id") - c.SetParamValues(tt.id) - resp, err := request.UserUpdate(c) - assert.Equal(t, tt.wantData, resp) - assert.Equal(t, tt.wantErr, err != nil) - }) - } -} diff --git a/cmd/api/service/account.go b/cmd/api/service/account.go deleted file mode 100644 index dc6dafe..0000000 --- a/cmd/api/service/account.go +++ /dev/null @@ -1,91 +0,0 @@ -package service - -import ( - "net/http" - - "github.com/labstack/echo" - "github.com/ribice/gorsk/internal" - - "github.com/ribice/gorsk/internal/account" - - "github.com/ribice/gorsk/cmd/api/request" -) - -// Account represents account http -type Account struct { - svc *account.Service -} - -// NewAccount creates new account http service -func NewAccount(svc *account.Service, ar *echo.Group) { - a := Account{svc: svc} - // swagger:route POST /v1/users users accCreate - // Creates new user account. - // responses: - // 200: userResp - // 400: errMsg - // 401: err - // 403: errMsg - // 500: err - ar.POST("", a.create) - // swagger:operation PATCH /v1/users/{id}/password users pwChange - // --- - // summary: Changes user's password. - // description: If user's old passowrd is correct, it will be replaced with new password. - // parameters: - // - name: id - // in: path - // description: id of user - // type: int - // required: true - // - name: request - // in: body - // description: Request body - // required: true - // schema: - // "$ref": "#/definitions/pwChange" - // responses: - // "200": - // "$ref": "#/responses/ok" - // "400": - // "$ref": "#/responses/errMsg" - // "401": - // "$ref": "#/responses/err" - // "403": - // "$ref": "#/responses/err" - // "500": - // "$ref": "#/responses/err" - ar.PATCH("/:id/password", a.changePassword) -} - -func (a *Account) create(c echo.Context) error { - r, err := request.AccountCreate(c) - if err != nil { - return err - } - usr, err := a.svc.Create(c, model.User{ - Username: r.Username, - Password: r.Password, - Email: r.Email, - FirstName: r.FirstName, - LastName: r.LastName, - CompanyID: r.CompanyID, - LocationID: r.LocationID, - RoleID: r.RoleID, - }) - if err != nil { - return err - } - return c.JSON(http.StatusOK, usr) -} - -func (a *Account) changePassword(c echo.Context) error { - p, err := request.PasswordChange(c) - if err != nil { - return err - } - if err := a.svc.ChangePassword(c, p.OldPassword, p.NewPassword, p.ID); err != nil { - return err - } - return c.NoContent(http.StatusOK) -} diff --git a/cmd/api/service/account_test.go b/cmd/api/service/account_test.go deleted file mode 100644 index acae80d..0000000 --- a/cmd/api/service/account_test.go +++ /dev/null @@ -1,182 +0,0 @@ -package service_test - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "github.com/go-pg/pg/orm" - "github.com/labstack/echo" - "github.com/stretchr/testify/assert" - - "github.com/ribice/gorsk/internal" - - "github.com/ribice/gorsk/cmd/api/server" - "github.com/ribice/gorsk/cmd/api/service" - "github.com/ribice/gorsk/internal/account" - "github.com/ribice/gorsk/internal/auth" - - "github.com/ribice/gorsk/internal/mock" - "github.com/ribice/gorsk/internal/mock/mockdb" -) - -func TestCreate(t *testing.T) { - cases := []struct { - name string - req string - wantStatus int - wantResp *model.User - adb *mockdb.Account - rbac *mock.RBAC - }{ - { - name: "Invalid request", - req: `{"first_name":"John","last_name":"Doe","username":"juzernejm","password":"hunter123","password_confirm":"hunter1234","email":"johndoe@gmail.com","company_id":1,"location_id":2,"role_id":3}`, - wantStatus: http.StatusBadRequest, - }, - { - name: "Fail on userSvc", - req: `{"first_name":"John","last_name":"Doe","username":"juzernejm","password":"hunter123","password_confirm":"hunter123","email":"johndoe@gmail.com","company_id":1,"location_id":2,"role_id":2}`, - rbac: &mock.RBAC{ - AccountCreateFn: func(c echo.Context, roleID model.AccessRole, companyID, locationID int) error { - return echo.ErrForbidden - }, - }, - wantStatus: http.StatusForbidden, - }, - { - name: "Success", - req: `{"first_name":"John","last_name":"Doe","username":"juzernejm","password":"hunter123","password_confirm":"hunter123","email":"johndoe@gmail.com","company_id":1,"location_id":2,"role_id":2}`, - rbac: &mock.RBAC{ - AccountCreateFn: func(c echo.Context, roleID model.AccessRole, companyID, locationID int) error { - return nil - }, - }, - adb: &mockdb.Account{ - CreateFn: func(db orm.DB, usr model.User) (*model.User, error) { - usr.ID = 1 - usr.CreatedAt = mock.TestTime(2018) - usr.UpdatedAt = mock.TestTime(2018) - return &usr, nil - }, - }, - wantResp: &model.User{ - Base: model.Base{ - ID: 1, - CreatedAt: mock.TestTime(2018), - UpdatedAt: mock.TestTime(2018), - }, - FirstName: "John", - LastName: "Doe", - Username: "juzernejm", - Email: "johndoe@gmail.com", - CompanyID: 1, - LocationID: 2, - }, - wantStatus: http.StatusOK, - }, - } - - for _, tt := range cases { - t.Run(tt.name, func(t *testing.T) { - r := server.New() - rg := r.Group("/v1/users") - service.NewAccount(account.New(nil, tt.adb, nil, tt.rbac), rg) - ts := httptest.NewServer(r) - defer ts.Close() - path := ts.URL + "/v1/users" - res, err := http.Post(path, "application/json", bytes.NewBufferString(tt.req)) - if err != nil { - t.Fatal(err) - } - defer res.Body.Close() - if tt.wantResp != nil { - response := new(model.User) - if err := json.NewDecoder(res.Body).Decode(response); err != nil { - t.Fatal(err) - } - assert.Equal(t, tt.wantResp, response) - } - assert.Equal(t, tt.wantStatus, res.StatusCode) - }) - } -} - -func TestChangePassword(t *testing.T) { - cases := []struct { - name string - req string - wantStatus int - id string - udb *mockdb.User - adb *mockdb.Account - rbac *mock.RBAC - }{ - { - name: "Invalid request", - req: `{"new_password":"new_password","old_password":"my_old_password", "new_password_confirm":"new_password_cf"}`, - wantStatus: http.StatusBadRequest, - id: "1", - }, - { - name: "Fail on RBAC", - req: `{"new_password":"newpassw","old_password":"oldpassw", "new_password_confirm":"newpassw"}`, - rbac: &mock.RBAC{ - EnforceUserFn: func(c echo.Context, id int) error { - return echo.ErrForbidden - }, - }, - id: "1", - wantStatus: http.StatusForbidden, - }, - { - name: "Success", - req: `{"new_password":"newpassw","old_password":"oldpassw", "new_password_confirm":"newpassw"}`, - rbac: &mock.RBAC{ - EnforceUserFn: func(c echo.Context, id int) error { - return nil - }, - }, - id: "1", - udb: &mockdb.User{ - ViewFn: func(db orm.DB, id int) (*model.User, error) { - return &model.User{ - Password: auth.HashPassword("oldpassw"), - }, nil - }, - }, - adb: &mockdb.Account{ - ChangePasswordFn: func(db orm.DB, usr *model.User) error { - return nil - }, - }, - wantStatus: http.StatusOK, - }, - } - - client := &http.Client{} - - for _, tt := range cases { - t.Run(tt.name, func(t *testing.T) { - r := server.New() - rg := r.Group("/v1/users") - service.NewAccount(account.New(nil, tt.adb, tt.udb, tt.rbac), rg) - ts := httptest.NewServer(r) - defer ts.Close() - path := ts.URL + "/v1/users/" + tt.id + "/password" - req, err := http.NewRequest("PATCH", path, bytes.NewBufferString(tt.req)) - req.Header.Set("Content-Type", "application/json") - if err != nil { - t.Fatal(err) - } - res, err := client.Do(req) - if err != nil { - t.Fatal(err) - } - defer res.Body.Close() - assert.Equal(t, tt.wantStatus, res.StatusCode) - }) - } -} diff --git a/cmd/api/swagger/user.go b/cmd/api/swagger/user.go deleted file mode 100644 index 7926fb5..0000000 --- a/cmd/api/swagger/user.go +++ /dev/null @@ -1,45 +0,0 @@ -package swagger - -import ( - "github.com/ribice/gorsk/internal" - - "github.com/ribice/gorsk/cmd/api/request" -) - -// Account create request -// swagger:parameters accCreate -type swaggAccCreateReq struct { - // in:body - Body request.Register -} - -// Password change request -// swagger:model pwChange -type swaggPwChange struct { - request.Password -} - -// User update request -// swagger:model userUpdate -type swaggUserUpdateReq struct { - request.UpdateUser -} - -// User model response -// swagger:response userResp -type swaggUserResponse struct { - // in:body - Body struct { - *model.User - } -} - -// Users model response -// swagger:response userListResp -type swaggUserListResponse struct { - // in:body - Body struct { - Users []model.User `json:"users"` - Page int `json:"page"` - } -} diff --git a/cmd/migration/main.go b/cmd/migration/main.go index 36d917d..aa866ba 100644 --- a/cmd/migration/main.go +++ b/cmd/migration/main.go @@ -2,26 +2,25 @@ package main import ( "fmt" - "github.com/ribice/gorsk/internal/auth" "log" "strings" - "github.com/go-pg/pg/orm" - - "github.com/ribice/gorsk/internal" + "github.com/ribice/gorsk/pkg/utl/model" + "github.com/ribice/gorsk/pkg/utl/secure" "github.com/go-pg/pg" + "github.com/go-pg/pg/orm" ) func main() { dbInsert := `INSERT INTO public.companies VALUES (1, now(), now(), NULL, 'admin_company', true); INSERT INTO public.locations VALUES (1, now(), now(), NULL, 'admin_location', true, 'admin_address', 1); - INSERT INTO public.roles VALUES (1, 1, 'SUPER_ADMIN'); - INSERT INTO public.roles VALUES (2, 2, 'ADMIN'); - INSERT INTO public.roles VALUES (3, 3, 'COMPANY_ADMIN'); - INSERT INTO public.roles VALUES (4, 4, 'LOCATION_ADMIN'); - INSERT INTO public.roles VALUES (5, 5, 'USER');` - var psn = `` + INSERT INTO public.roles VALUES (100, 100, 'SUPER_ADMIN'); + INSERT INTO public.roles VALUES (110, 110, 'ADMIN'); + INSERT INTO public.roles VALUES (120, 120, 'COMPANY_ADMIN'); + INSERT INTO public.roles VALUES (130, 130, 'LOCATION_ADMIN'); + INSERT INTO public.roles VALUES (200, 200, 'USER');` + var psn = `postgres://biadpozi:3_Czbl7jSjkUEWk--VP8QXMke-mFnczq@horton.elephantsql.com:5432/biadpozi` queries := strings.Split(dbInsert, ";") u, err := pg.ParseURL(psn) @@ -29,14 +28,17 @@ func main() { db := pg.Connect(u) _, err = db.Exec("SELECT 1") checkErr(err) - createSchema(db, &model.Company{}, &model.Location{}, &model.Role{}, &model.User{}) + createSchema(db, &gorsk.Company{}, &gorsk.Location{}, &gorsk.Role{}, &gorsk.User{}) for _, v := range queries[0 : len(queries)-1] { _, err := db.Exec(v) checkErr(err) } - userInsert := `INSERT INTO public.users VALUES (1, now(),now(), NULL, 'Admin', 'Admin', 'admin', '%s', 'johndoe@mail.com', NULL, NULL, NULL, NULL, true, NULL, 1, 1, 1);` - _, err = db.Exec(fmt.Sprintf(userInsert, auth.HashPassword("admin"))) + + sec := secure.New(1, nil) + + userInsert := `INSERT INTO public.users (id, created_at, updated_at, first_name, last_name, username, password, email, active, role_id, company_id, location_id) VALUES (1, now(),now(),'Admin', 'Admin', 'admin', '%s', 'johndoe@mail.com', true, 100, 1, 1);` + _, err = db.Exec(fmt.Sprintf(userInsert, sec.Hash("admin"))) checkErr(err) } diff --git a/internal/account/account.go b/internal/account/account.go deleted file mode 100644 index 0b5d00e..0000000 --- a/internal/account/account.go +++ /dev/null @@ -1,55 +0,0 @@ -package account - -import ( - "net/http" - - "github.com/go-pg/pg" - "github.com/labstack/echo" - - "github.com/ribice/gorsk/internal" - - "github.com/ribice/gorsk/internal/auth" -) - -// New creates new user application service -func New(db *pg.DB, adb model.AccountDB, udb model.UserDB, rbac model.RBACService) *Service { - return &Service{ - db: db, - adb: adb, - udb: udb, - rbac: rbac, - } -} - -// Service represents account application service -type Service struct { - db *pg.DB - adb model.AccountDB - udb model.UserDB - rbac model.RBACService -} - -// Create creates a new user account -func (s *Service) Create(c echo.Context, req model.User) (*model.User, error) { - if err := s.rbac.AccountCreate(c, req.RoleID, req.CompanyID, req.LocationID); err != nil { - return nil, err - } - req.Password = auth.HashPassword(req.Password) - return s.adb.Create(s.db, req) -} - -// ChangePassword changes user's password -func (s *Service) ChangePassword(c echo.Context, oldPass, newPass string, id int) error { - if err := s.rbac.EnforceUser(c, id); err != nil { - return err - } - u, err := s.udb.View(s.db, id) - if err != nil { - return err - } - if !auth.HashMatchesPassword(u.Password, oldPass) { - return echo.NewHTTPError(http.StatusBadRequest, "old password is not correct") - } - u.Password = auth.HashPassword(newPass) - return s.adb.ChangePassword(s.db, u) -} diff --git a/internal/account/account_test.go b/internal/account/account_test.go deleted file mode 100644 index 7551d53..0000000 --- a/internal/account/account_test.go +++ /dev/null @@ -1,176 +0,0 @@ -package account_test - -import ( - "testing" - - "github.com/go-pg/pg/orm" - "github.com/labstack/echo" - - "github.com/ribice/gorsk/internal/mock" - "github.com/stretchr/testify/assert" - - "github.com/ribice/gorsk/internal" - "github.com/ribice/gorsk/internal/account" - "github.com/ribice/gorsk/internal/mock/mockdb" -) - -func TestCreate(t *testing.T) { - type args struct { - c echo.Context - req model.User - } - cases := []struct { - name string - args args - wantErr bool - wantData *model.User - adb *mockdb.Account - udb *mockdb.User - rbac *mock.RBAC - }{{ - name: "Fail on is lower role", - rbac: &mock.RBAC{ - AccountCreateFn: func(echo.Context, model.AccessRole, int, int) error { - return model.ErrGeneric - }}, - wantErr: true, - args: args{req: model.User{ - FirstName: "John", - LastName: "Doe", - Username: "JohnDoe", - RoleID: 1, - Password: "Thranduil8822", - }}, - }, - { - name: "Success", - args: args{req: model.User{ - FirstName: "John", - LastName: "Doe", - Username: "JohnDoe", - RoleID: 1, - Password: "Thranduil8822", - }}, - adb: &mockdb.Account{ - CreateFn: func(db orm.DB, u model.User) (*model.User, error) { - u.CreatedAt = mock.TestTime(2000) - u.UpdatedAt = mock.TestTime(2000) - u.Base.ID = 1 - return &u, nil - }, - }, - rbac: &mock.RBAC{ - AccountCreateFn: func(echo.Context, model.AccessRole, int, int) error { - return nil - }}, - wantData: &model.User{ - Base: model.Base{ - ID: 1, - CreatedAt: mock.TestTime(2000), - UpdatedAt: mock.TestTime(2000), - }, - FirstName: "John", - LastName: "Doe", - Username: "JohnDoe", - RoleID: 1, - }}} - for _, tt := range cases { - t.Run(tt.name, func(t *testing.T) { - s := account.New(nil, tt.adb, tt.udb, tt.rbac) - usr, err := s.Create(tt.args.c, tt.args.req) - assert.Equal(t, tt.wantErr, err != nil) - if tt.wantData != nil { - tt.wantData.Password = usr.Password - assert.Equal(t, tt.wantData, usr) - } - }) - } -} - -func TestChangePassword(t *testing.T) { - type args struct { - c echo.Context - oldpass string - newpass string - id int - } - cases := []struct { - name string - args args - wantErr bool - udb *mockdb.User - adb *mockdb.Account - rbac *mock.RBAC - }{ - { - name: "Fail on EnforceUser", - args: args{id: 1}, - rbac: &mock.RBAC{ - EnforceUserFn: func(c echo.Context, id int) error { - return model.ErrGeneric - }}, - wantErr: true, - }, - { - name: "Fail on ViewUser", - args: args{id: 1}, - wantErr: true, - rbac: &mock.RBAC{ - EnforceUserFn: func(c echo.Context, id int) error { - return nil - }}, - udb: &mockdb.User{ - ViewFn: func(db orm.DB, id int) (*model.User, error) { - if id != 1 { - return nil, nil - } - return nil, model.ErrGeneric - }, - }, - }, - { - name: "Fail on PasswordMatch", - args: args{id: 1, oldpass: "hunter123"}, - rbac: &mock.RBAC{ - EnforceUserFn: func(c echo.Context, id int) error { - return nil - }}, - wantErr: true, - udb: &mockdb.User{ - ViewFn: func(db orm.DB, id int) (*model.User, error) { - return &model.User{ - Password: "IncorrectHashedPassword", - }, nil - }, - }, - }, - { - name: "Success", - args: args{id: 1, oldpass: "hunter123", newpass: "password"}, - rbac: &mock.RBAC{ - EnforceUserFn: func(c echo.Context, id int) error { - return nil - }}, - udb: &mockdb.User{ - ViewFn: func(db orm.DB, id int) (*model.User, error) { - return &model.User{ - Password: "$2a$10$udRBroNGBeOYwSWCVzf6Lulg98uAoRCIi4t75VZg84xgw6EJbFNsG", - }, nil - }, - }, - adb: &mockdb.Account{ - // Check whether password was hashed correctly - ChangePasswordFn: func(db orm.DB, usr *model.User) error { - return nil - }, - }, - }, - } - for _, tt := range cases { - t.Run(tt.name, func(t *testing.T) { - s := account.New(nil, tt.adb, tt.udb, tt.rbac) - err := s.ChangePassword(tt.args.c, tt.args.oldpass, tt.args.newpass, tt.args.id) - assert.Equal(t, tt.wantErr, err != nil) - }) - } -} diff --git a/internal/auth/auth.go b/internal/auth/auth.go deleted file mode 100644 index 95e31f6..0000000 --- a/internal/auth/auth.go +++ /dev/null @@ -1,111 +0,0 @@ -package auth - -import ( - "net/http" - - "github.com/go-pg/pg" - "github.com/labstack/echo" - - "github.com/rs/xid" - - "github.com/ribice/gorsk/internal" - - "golang.org/x/crypto/bcrypt" -) - -// New creates new auth service -func New(db *pg.DB, udb model.UserDB, j JWT) *Service { - return &Service{ - db: db, - udb: udb, - jwt: j, - } -} - -// Service represents auth application service -type Service struct { - db *pg.DB - udb model.UserDB - jwt JWT -} - -// JWT represents jwt interface -type JWT interface { - GenerateToken(*model.User) (string, string, error) -} - -// Authenticate tries to authenticate the user provided by username and password -func (s *Service) Authenticate(c echo.Context, user, pass string) (*model.AuthToken, error) { - u, err := s.udb.FindByUsername(s.db, user) - if err != nil { - return nil, err - } - if !HashMatchesPassword(u.Password, pass) { - return nil, echo.NewHTTPError(http.StatusUnauthorized, "Username or password does not exist") - } - - if !u.Active { - return nil, echo.NewHTTPError(http.StatusUnauthorized) - } - token, expire, err := s.jwt.GenerateToken(u) - if err != nil { - return nil, echo.NewHTTPError(http.StatusUnauthorized) - } - - u.UpdateLastLogin() - u.Token = xid.New().String() - _, err = s.udb.Update(s.db, u) - if err != nil { - return nil, err - } - - return &model.AuthToken{Token: token, Expires: expire, RefreshToken: u.Token}, nil -} - -// Refresh refreshes jwt token and puts new claims inside -func (s *Service) Refresh(c echo.Context, token string) (*model.RefreshToken, error) { - user, err := s.udb.FindByToken(s.db, token) - if err != nil { - return nil, err - } - token, expire, err := s.jwt.GenerateToken(user) - if err != nil { - return nil, model.ErrGeneric - } - return &model.RefreshToken{Token: token, Expires: expire}, nil -} - -// Me returns info about currently logged user -func (s *Service) Me(c echo.Context) (*model.User, error) { - au := s.User(c) - return s.udb.View(s.db, au.ID) -} - -// User returns user data stored in jwt token -func (s *Service) User(c echo.Context) *model.AuthUser { - id := c.Get("id").(int) - companyID := c.Get("company_id").(int) - locationID := c.Get("location_id").(int) - user := c.Get("username").(string) - email := c.Get("email").(string) - role := c.Get("role").(model.AccessRole) - return &model.AuthUser{ - ID: id, - Username: user, - CompanyID: companyID, - LocationID: locationID, - Email: email, - Role: model.AccessRole(role), - } -} - -// HashPassword hashes the password using bcrypt -func HashPassword(password string) string { - hashedPW, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) - return string(hashedPW) -} - -// HashMatchesPassword matches hash with password. Returns true if hash and password match. -func HashMatchesPassword(hash, password string) bool { - return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil -} diff --git a/internal/mock/auth.go b/internal/mock/auth.go deleted file mode 100644 index 5661d68..0000000 --- a/internal/mock/auth.go +++ /dev/null @@ -1,17 +0,0 @@ -package mock - -import ( - "github.com/labstack/echo" - - "github.com/ribice/gorsk/internal" -) - -// Auth mock -type Auth struct { - UserFn func(echo.Context) *model.AuthUser -} - -// User mock -func (a *Auth) User(c echo.Context) *model.AuthUser { - return a.UserFn(c) -} diff --git a/internal/mock/mockdb/account.go b/internal/mock/mockdb/account.go deleted file mode 100644 index ad20eaa..0000000 --- a/internal/mock/mockdb/account.go +++ /dev/null @@ -1,22 +0,0 @@ -package mockdb - -import ( - "github.com/go-pg/pg/orm" - "github.com/ribice/gorsk/internal" -) - -// Account database mock -type Account struct { - CreateFn func(orm.DB, model.User) (*model.User, error) - ChangePasswordFn func(orm.DB, *model.User) error -} - -// Create mock -func (a *Account) Create(db orm.DB, usr model.User) (*model.User, error) { - return a.CreateFn(db, usr) -} - -// ChangePassword mock -func (a *Account) ChangePassword(db orm.DB, usr *model.User) error { - return a.ChangePasswordFn(db, usr) -} diff --git a/internal/mock/mockdb/user.go b/internal/mock/mockdb/user.go deleted file mode 100644 index c01b717..0000000 --- a/internal/mock/mockdb/user.go +++ /dev/null @@ -1,46 +0,0 @@ -package mockdb - -import ( - "github.com/go-pg/pg/orm" - "github.com/ribice/gorsk/internal" -) - -// User database mock -type User struct { - ViewFn func(orm.DB, int) (*model.User, error) - FindByUsernameFn func(orm.DB, string) (*model.User, error) - FindByTokenFn func(orm.DB, string) (*model.User, error) - ListFn func(orm.DB, *model.ListQuery, *model.Pagination) ([]model.User, error) - DeleteFn func(orm.DB, *model.User) error - UpdateFn func(orm.DB, *model.User) (*model.User, error) -} - -// View mock -func (u *User) View(db orm.DB, id int) (*model.User, error) { - return u.ViewFn(db, id) -} - -// FindByUsername mock -func (u *User) FindByUsername(db orm.DB, username string) (*model.User, error) { - return u.FindByUsernameFn(db, username) -} - -// FindByToken mock -func (u *User) FindByToken(db orm.DB, token string) (*model.User, error) { - return u.FindByTokenFn(db, token) -} - -// List mock -func (u *User) List(db orm.DB, lq *model.ListQuery, p *model.Pagination) ([]model.User, error) { - return u.ListFn(db, lq, p) -} - -// Delete mock -func (u *User) Delete(db orm.DB, usr *model.User) error { - return u.DeleteFn(db, usr) -} - -// Update mock -func (u *User) Update(db orm.DB, usr *model.User) (*model.User, error) { - return u.UpdateFn(db, usr) -} diff --git a/internal/mock/mw.go b/internal/mock/mw.go deleted file mode 100644 index 39ce202..0000000 --- a/internal/mock/mw.go +++ /dev/null @@ -1,15 +0,0 @@ -package mock - -import ( - "github.com/ribice/gorsk/internal" -) - -// JWT mock -type JWT struct { - GenerateTokenFn func(*model.User) (string, string, error) -} - -// GenerateToken mock -func (j *JWT) GenerateToken(u *model.User) (string, string, error) { - return j.GenerateTokenFn(u) -} diff --git a/internal/model_test.go b/internal/model_test.go deleted file mode 100644 index a5d850a..0000000 --- a/internal/model_test.go +++ /dev/null @@ -1,34 +0,0 @@ -package model_test - -import ( - "testing" - - "github.com/ribice/gorsk/internal" - - "github.com/ribice/gorsk/internal/mock" -) - -func TestBeforeInsert(t *testing.T) { - base := &model.Base{ - ID: 1, - } - base.BeforeInsert(nil) - if base.CreatedAt.IsZero() { - t.Errorf("CreatedAt was not changed") - } - if base.UpdatedAt.IsZero() { - t.Errorf("UpdatedAt was not changed") - } -} - -func TestBeforeUpdate(t *testing.T) { - base := &model.Base{ - ID: 1, - CreatedAt: mock.TestTime(2000), - } - base.BeforeUpdate(nil) - if base.UpdatedAt == mock.TestTime(2001) { - t.Errorf("UpdatedAt was not changed") - } - -} diff --git a/internal/platform/postgres/account.go b/internal/platform/postgres/account.go deleted file mode 100644 index 278f5d3..0000000 --- a/internal/platform/postgres/account.go +++ /dev/null @@ -1,46 +0,0 @@ -package pgsql - -import ( - "net/http" - - "github.com/go-pg/pg/orm" - "github.com/labstack/echo" - "github.com/ribice/gorsk/internal" -) - -// NewAccountDB returns a new AccountDB instance -func NewAccountDB(l echo.Logger) *AccountDB { - return &AccountDB{l} -} - -// AccountDB represents the client for user table -type AccountDB struct { - log echo.Logger -} - -// Create creates a new user on database -func (a *AccountDB) Create(db orm.DB, usr model.User) (*model.User, error) { - var user = new(model.User) - res, err := db.Query(user, "select id from users where username = ? or email = ? and deleted_at is null", usr.Username, usr.Email) - if err != nil { - a.log.Error("AccountDB Error: %v", err) - return nil, err - } - if res.RowsReturned() != 0 { - return nil, echo.NewHTTPError(http.StatusInternalServerError, "Username or email already exists.") - } - if err := db.Insert(&usr); err != nil { - a.log.Error("AccountDB Error: %v", err) - return nil, err - } - return &usr, nil -} - -// ChangePassword changes user's password -func (a *AccountDB) ChangePassword(db orm.DB, usr *model.User) error { - _, err := db.Model(usr).Column("password", "updated_at").WherePK().Update() - if err != nil { - a.log.Warnf("AccountDB Error: %v", err) - } - return err -} diff --git a/internal/platform/postgres/account_test.go b/internal/platform/postgres/account_test.go deleted file mode 100644 index 36eaf96..0000000 --- a/internal/platform/postgres/account_test.go +++ /dev/null @@ -1,164 +0,0 @@ -package pgsql_test - -import ( - "testing" - - "github.com/labstack/echo" - "github.com/ribice/gorsk/internal/mock" - "github.com/ribice/gorsk/internal/platform/postgres" - "github.com/stretchr/testify/assert" - - "github.com/ribice/gorsk/internal" - - "github.com/go-pg/pg" -) - -func testAccountDB(t *testing.T, c *pg.DB, l echo.Logger) { - accDB := pgsql.NewAccountDB(l) - cases := []struct { - name string - fn func(*testing.T, *pgsql.AccountDB, *pg.DB) - }{ - { - name: "accountCreate", - fn: testAccountCreate, - }, - { - name: "changePassword", - fn: testChangePassword, - }, - } - for _, tt := range cases { - t.Run(tt.name, func(t *testing.T) { - tt.fn(t, accDB, c) - }) - } - -} - -func testAccountCreate(t *testing.T, db *pgsql.AccountDB, c *pg.DB) { - cases := []struct { - name string - wantErr bool - usr model.User - wantData *model.User - }{ - { - name: "User already exists", - wantErr: true, - usr: model.User{ - Email: "johndoe@mail.com", - Username: "johndoe", - }, - }, - { - name: "Fail on insert duplicate ID", - wantErr: true, - usr: model.User{ - Email: "tomjones@mail.com", - FirstName: "Tom", - LastName: "Jones", - Username: "tomjones", - RoleID: model.AccessRole(1), - CompanyID: 1, - LocationID: 1, - Password: "pass", - Base: model.Base{ - ID: 1, - }, - }, - }, - { - name: "Success", - usr: model.User{ - Email: "tomjones@mail.com", - FirstName: "Tom", - LastName: "Jones", - Username: "tomjones", - RoleID: model.AccessRole(1), - CompanyID: 1, - LocationID: 1, - Password: "pass", - Base: model.Base{ - ID: 2, - }, - }, - wantData: &model.User{ - Email: "tomjones@mail.com", - FirstName: "Tom", - LastName: "Jones", - Username: "tomjones", - RoleID: model.AccessRole(1), - CompanyID: 1, - LocationID: 1, - Password: "pass", - Base: model.Base{ - ID: 2, - }, - }, - }, - } - for _, tt := range cases { - t.Run(tt.name, func(t *testing.T) { - usr, err := db.Create(c, tt.usr) - assert.Equal(t, tt.wantErr, err != nil) - if tt.wantData != nil { - tt.wantData.CreatedAt = usr.CreatedAt - tt.wantData.UpdatedAt = usr.UpdatedAt - assert.Equal(t, tt.wantData, usr) - } - }) - } -} - -func testChangePassword(t *testing.T, db *pgsql.AccountDB, c *pg.DB) { - cases := []struct { - name string - wantErr bool - usr *model.User - wantData *model.User - }{ - // Does not fail on this test, but should - // { - // name: "User does not exist", - // wantErr: true, - // usr: &model.User{}, - // }, - { - name: "Success", - usr: &model.User{ - Base: model.Base{ - ID: 2, - UpdatedAt: mock.TestTime(2000), - }, - Password: "newPass", - }, - wantData: &model.User{ - Email: "tomjones@mail.com", - FirstName: "Tom", - LastName: "Jones", - Username: "tomjones", - RoleID: 1, - CompanyID: 1, - LocationID: 1, - Password: "newPass", - Base: model.Base{ - ID: 2, - }, - }, - }, - } - for _, tt := range cases { - t.Run(tt.name, func(t *testing.T) { - err := db.ChangePassword(c, tt.usr) - assert.Equal(t, tt.wantErr, err != nil) - if tt.wantData != nil { - userDB := queryUser(t, c, tt.usr.Base.ID) - assert.NotEqual(t, tt.usr.UpdatedAt, userDB.UpdatedAt) - tt.wantData.UpdatedAt = userDB.UpdatedAt - tt.wantData.CreatedAt = userDB.CreatedAt - assert.Equal(t, tt.wantData, userDB) - } - }) - } -} diff --git a/internal/platform/postgres/pg.go b/internal/platform/postgres/pg.go deleted file mode 100644 index b12bd2f..0000000 --- a/internal/platform/postgres/pg.go +++ /dev/null @@ -1,55 +0,0 @@ -package pgsql - -import ( - "log" - "time" - - "github.com/go-pg/pg" - // DB adapter - _ "github.com/lib/pq" - "github.com/ribice/gorsk/internal" - - "github.com/ribice/gorsk/cmd/api/config" -) - -const notDeleted = "deleted_at is null" - -// New creates new database connection to a postgres database -// Function panics if it can't connect to database -func New(cfg *config.Database) (*pg.DB, error) { - u, err := pg.ParseURL(cfg.PSN) - if err != nil { - return nil, err - } - db := pg.Connect(u) - _, err = db.Exec("SELECT 1") - if err != nil { - return nil, err - } - if cfg.Timeout > 0 { - db.WithTimeout(time.Second * time.Duration(cfg.Timeout)) - } - if cfg.Log { - db.OnQueryProcessed(func(event *pg.QueryProcessedEvent) { - query, err := event.FormattedQuery() - checkErr(err) - log.Printf("%s | %s", time.Since(event.StartTime), query) - }) - } - if cfg.CreateSchema { - createSchema(db, &model.Company{}, &model.Location{}, &model.Role{}, &model.User{}) - } - return db, nil -} - -func createSchema(db *pg.DB, models ...interface{}) { - for _, model := range models { - checkErr(db.CreateTable(model, nil)) - } -} - -func checkErr(err error) { - if err != nil { - log.Fatal(err) - } -} diff --git a/internal/platform/postgres/pg_test.go b/internal/platform/postgres/pg_test.go deleted file mode 100644 index 29903d7..0000000 --- a/internal/platform/postgres/pg_test.go +++ /dev/null @@ -1,115 +0,0 @@ -package pgsql_test - -import ( - "database/sql" - "strings" - "testing" - - "github.com/labstack/echo" - "github.com/ribice/gorsk/internal" - "github.com/ribice/gorsk/internal/platform/postgres" - - "github.com/ribice/gorsk/cmd/api/config" - - "github.com/fortytw2/dockertest" - "github.com/go-pg/pg" -) - -func TestNew(t *testing.T) { - container, err := dockertest.RunContainer("postgres:alpine", "5432", func(addr string) error { - db, err := sql.Open("postgres", "postgres://postgres:postgres@"+addr+"?sslmode=disable") - if err != nil { - return err - } - - return db.Ping() - }) - defer container.Shutdown() - if err != nil { - t.Fatalf("could not start postgres, %s", err) - } - - _, err = pgsql.New(&config.Database{PSN: "PSN"}) - if err == nil { - t.Error("Expected error") - } - - _, err = pgsql.New(&config.Database{PSN: "postgres://postgres:postgres@localhost:1234/postgres?sslmode=disable"}) - if err == nil { - t.Error("Expected error") - } - - dbLogTest, err := pgsql.New(&config.Database{PSN: "postgres://postgres:postgres@" + container.Addr + "/postgres?sslmode=disable", Log: true}) - if err != nil { - t.Fatalf("Error establishing connection %v", err) - } - dbLogTest.Close() - - dbCfg := &config.Database{PSN: "postgres://postgres:postgres@" + container.Addr + "/postgres?sslmode=disable", CreateSchema: true} - - db, err := pgsql.New(dbCfg) - if err != nil { - t.Fatalf("Error establishing connection %v", err) - } - - defer db.Close() - - cases := []struct { - name string - fn func(t *testing.T, db *pg.DB, log echo.Logger) - }{ - { - name: "AccountDB", - fn: testAccountDB, - }, - { - name: "UserDB", - fn: testUserDB, - }, - } - - seedData(t, db) - - e := echo.New() - - for _, tt := range cases { - tt := tt - t.Run(tt.name, func(t *testing.T) { - tt.fn(t, db, e.Logger) - }) - } - -} - -func seedData(t *testing.T, db *pg.DB) { - - dbInsert := `INSERT INTO companies VALUES (1, now(), now(), NULL, 'admin_company', true); -INSERT INTO locations VALUES (1, now(), now(), NULL, 'admin_location', true, 'admin_address', 1); -INSERT INTO roles VALUES (1, 1, 'SUPER_ADMIN'); -INSERT INTO roles VALUES (2, 2, 'ADMIN'); -INSERT INTO roles VALUES (3, 3, 'COMPANY_ADMIN'); -INSERT INTO roles VALUES (4, 4, 'LOCATION_ADMIN'); -INSERT INTO roles VALUES (5, 5, 'USER'); -INSERT INTO users VALUES (1, now(),now(), NULL, 'John', 'Doe', 'johndoe', 'hunter2', 'johndoe@mail.com', NULL, NULL, NULL, NULL, NULL, 'loginrefresh',1, 1, 1);` - - queries := strings.Split(dbInsert, ";") - for _, v := range queries[0 : len(queries)-1] { - _, err := db.Exec(v) - if err != nil { - t.Fatalf("Fail on seeding data: %v", err) - } - - } -} - -func queryUser(t *testing.T, db *pg.DB, id int) *model.User { - user := &model.User{ - Base: model.Base{ - ID: id, - }, - } - if err := db.Select(user); err != nil { - t.Errorf("Could not get user with ID %d due to error %v", id, err) - } - return user -} diff --git a/internal/platform/postgres/user.go b/internal/platform/postgres/user.go deleted file mode 100644 index 4d6f2a6..0000000 --- a/internal/platform/postgres/user.go +++ /dev/null @@ -1,88 +0,0 @@ -package pgsql - -import ( - "github.com/go-pg/pg/orm" - "github.com/labstack/echo" - "github.com/ribice/gorsk/internal" -) - -// NewUserDB returns a new UserDB instance -func NewUserDB(l echo.Logger) *UserDB { - return &UserDB{l} -} - -// UserDB represents the client for user table -type UserDB struct { - log echo.Logger -} - -// View returns single user by ID -func (u *UserDB) View(db orm.DB, id int) (*model.User, error) { - var user = new(model.User) - sql := `SELECT "user".*, "role"."id" AS "role__id", "role"."access_level" AS "role__access_level", "role"."name" AS "role__name" - FROM "users" AS "user" LEFT JOIN "roles" AS "role" ON "role"."id" = "user"."role_id" - WHERE ("user"."id" = ? and deleted_at is null)` - _, err := db.QueryOne(user, sql, id) - if err != nil { - u.log.Warnf("UserDB Error: %v", err) - } - return user, err -} - -// FindByUsername queries for single user by username -func (u *UserDB) FindByUsername(db orm.DB, uname string) (*model.User, error) { - var user = new(model.User) - sql := `SELECT "user".*, "role"."id" AS "role__id", "role"."access_level" AS "role__access_level", "role"."name" AS "role__name" - FROM "users" AS "user" LEFT JOIN "roles" AS "role" ON "role"."id" = "user"."role_id" - WHERE ("user"."username" = ? and deleted_at is null)` - _, err := db.QueryOne(user, sql, uname) - if err != nil { - u.log.Warnf("UserDB Error: %v", err) - } - return user, err -} - -// FindByToken queries for single user by token -func (u *UserDB) FindByToken(db orm.DB, token string) (*model.User, error) { - var user = new(model.User) - sql := `SELECT "user".*, "role"."id" AS "role__id", "role"."access_level" AS "role__access_level", "role"."name" AS "role__name" - FROM "users" AS "user" LEFT JOIN "roles" AS "role" ON "role"."id" = "user"."role_id" - WHERE ("user"."token" = ? and deleted_at is null)` - _, err := db.QueryOne(user, sql, token) - if err != nil { - u.log.Warnf("UserDB Error: %v", err) - } - return user, err -} - -// List returns list of all users retrievable for the current user, depending on role -func (u *UserDB) List(db orm.DB, qp *model.ListQuery, p *model.Pagination) ([]model.User, error) { - var users []model.User - q := db.Model(&users).Column("user.*", "Role").Limit(p.Limit).Offset(p.Offset).Where(notDeleted).Order("user.id desc") - if qp != nil { - q.Where(qp.Query, qp.ID) - } - if err := q.Select(); err != nil { - u.log.Warnf("UserDB Error: %v", err) - return nil, err - } - return users, nil -} - -// Delete sets deleted_at for a user -func (u *UserDB) Delete(db orm.DB, user *model.User) error { - err := db.Delete(user) - if err != nil { - u.log.Warnf("UserDB Error: %v", err) - } - return err -} - -// Update updates user's contact info -func (u *UserDB) Update(db orm.DB, user *model.User) (*model.User, error) { - _, err := db.Model(user).WherePK().Update() - if err != nil { - u.log.Warnf("UserDB Error: %v", err) - } - return user, err -} diff --git a/internal/platform/postgres/user_test.go b/internal/platform/postgres/user_test.go deleted file mode 100644 index 64d6caa..0000000 --- a/internal/platform/postgres/user_test.go +++ /dev/null @@ -1,409 +0,0 @@ -package pgsql_test - -import ( - "testing" - - "github.com/labstack/echo" - "github.com/ribice/gorsk/internal/platform/postgres" - "github.com/stretchr/testify/assert" - - "github.com/go-pg/pg" - "github.com/ribice/gorsk/internal" - "github.com/ribice/gorsk/internal/mock" -) - -func testUserDB(t *testing.T, c *pg.DB, l echo.Logger) { - userDB := pgsql.NewUserDB(l) - cases := []struct { - name string - fn func(*testing.T, *pgsql.UserDB, *pg.DB) - }{ - { - name: "view", - fn: testUserView, - }, - { - name: "findByUsername", - fn: testUserFindByUsername, - }, - { - name: "findByToken", - fn: testUserFindByToken, - }, - { - name: "userList", - fn: testUserList, - }, - { - name: "delete", - fn: testUserDelete, - }, - { - name: "update", - fn: testUserUpdate, - }, - } - for _, tt := range cases { - t.Run(tt.name, func(t *testing.T) { - tt.fn(t, userDB, c) - }) - } - -} - -func testUserView(t *testing.T, db *pgsql.UserDB, c *pg.DB) { - cases := []struct { - name string - wantErr bool - id int - wantData *model.User - }{ - { - name: "User does not exist", - wantErr: true, - id: 1000, - }, - { - name: "Success", - id: 2, - wantData: &model.User{ - Email: "tomjones@mail.com", - FirstName: "Tom", - LastName: "Jones", - Username: "tomjones", - RoleID: 1, - CompanyID: 1, - LocationID: 1, - Password: "newPass", - Base: model.Base{ - ID: 2, - }, - Role: &model.Role{ - ID: 1, - AccessLevel: 1, - Name: "SUPER_ADMIN", - }, - }, - }, - } - for _, tt := range cases { - t.Run(tt.name, func(t *testing.T) { - user, err := db.View(c, tt.id) - assert.Equal(t, tt.wantErr, err != nil) - if tt.wantData != nil { - tt.wantData.CreatedAt = user.CreatedAt - tt.wantData.UpdatedAt = user.UpdatedAt - assert.Equal(t, tt.wantData, user) - } - }) - } -} - -func testUserFindByUsername(t *testing.T, db *pgsql.UserDB, c *pg.DB) { - cases := []struct { - name string - wantErr bool - username string - wantData *model.User - }{ - { - name: "User does not exist", - wantErr: true, - username: "notExists", - }, - { - name: "Success", - username: "tomjones", - wantData: &model.User{ - Email: "tomjones@mail.com", - FirstName: "Tom", - LastName: "Jones", - Username: "tomjones", - RoleID: 1, - CompanyID: 1, - LocationID: 1, - Password: "newPass", - Base: model.Base{ - ID: 2, - }, - Role: &model.Role{ - ID: 1, - AccessLevel: 1, - Name: "SUPER_ADMIN", - }, - }, - }, - } - for _, tt := range cases { - t.Run(tt.name, func(t *testing.T) { - user, err := db.FindByUsername(c, tt.username) - assert.Equal(t, tt.wantErr, err != nil) - - if tt.wantData != nil { - tt.wantData.CreatedAt = user.CreatedAt - tt.wantData.UpdatedAt = user.UpdatedAt - assert.Equal(t, tt.wantData, user) - - } - }) - } -} - -func testUserFindByToken(t *testing.T, db *pgsql.UserDB, c *pg.DB) { - cases := []struct { - name string - wantErr bool - token string - wantData *model.User - }{ - { - name: "User does not exist", - wantErr: true, - token: "notExists", - }, - { - name: "Success", - token: "loginrefresh", - wantData: &model.User{ - Email: "johndoe@mail.com", - FirstName: "John", - LastName: "Doe", - Username: "johndoe", - RoleID: 1, - CompanyID: 1, - LocationID: 1, - Password: "hunter2", - Base: model.Base{ - ID: 1, - }, - Role: &model.Role{ - ID: 1, - AccessLevel: 1, - Name: "SUPER_ADMIN", - }, - Token: "loginrefresh", - }, - }, - } - for _, tt := range cases { - t.Run(tt.name, func(t *testing.T) { - user, err := db.FindByToken(c, tt.token) - assert.Equal(t, tt.wantErr, err != nil) - - if tt.wantData != nil { - tt.wantData.CreatedAt = user.CreatedAt - tt.wantData.UpdatedAt = user.UpdatedAt - assert.Equal(t, tt.wantData, user) - - } - }) - } -} - -func testUserList(t *testing.T, db *pgsql.UserDB, c *pg.DB) { - cases := []struct { - name string - wantErr bool - qp *model.ListQuery - pg *model.Pagination - wantData []model.User - }{ - { - name: "Invalid pagination values", - wantErr: true, - pg: &model.Pagination{ - Limit: -100, - }, - }, - { - name: "Success", - pg: &model.Pagination{ - Limit: 100, - Offset: 0, - }, - qp: &model.ListQuery{ - ID: 1, - Query: "company_id = ?", - }, - wantData: []model.User{ - { - Email: "tomjones@mail.com", - FirstName: "Tom", - LastName: "Jones", - Username: "tomjones", - RoleID: model.AccessRole(1), - CompanyID: 1, - LocationID: 1, - Password: "newPass", - Base: model.Base{ - ID: 2, - }, - Role: &model.Role{ - ID: 1, - AccessLevel: 1, - Name: "SUPER_ADMIN", - }, - }, - { - Email: "johndoe@mail.com", - FirstName: "John", - LastName: "Doe", - Username: "johndoe", - RoleID: model.AccessRole(1), - CompanyID: 1, - LocationID: 1, - Password: "hunter2", - Base: model.Base{ - ID: 1, - }, - Role: &model.Role{ - ID: 1, - AccessLevel: 1, - Name: "SUPER_ADMIN", - }, - Token: "loginrefresh", - }, - }, - }, - } - for _, tt := range cases { - t.Run(tt.name, func(t *testing.T) { - users, err := db.List(c, tt.qp, tt.pg) - assert.Equal(t, tt.wantErr, err != nil) - if tt.wantData != nil { - for i, v := range users { - tt.wantData[i].CreatedAt = v.CreatedAt - tt.wantData[i].UpdatedAt = v.UpdatedAt - } - assert.Equal(t, tt.wantData, users) - } - }) - } -} - -func testUserDelete(t *testing.T, db *pgsql.UserDB, c *pg.DB) { - cases := []struct { - name string - wantErr bool - usr *model.User - wantData *model.User - }{ - // Does not fail on this test, but should - // { - // name: "User does not exist", - // wantErr: true, - // usr: &model.User{}, - // }, - { - name: "Success", - usr: &model.User{ - Base: model.Base{ - ID: 2, - DeletedAt: mock.TestTimePtr(2018), - }, - }, - wantData: &model.User{ - Email: "tomjones@mail.com", - FirstName: "Tom", - LastName: "Jones", - Username: "tomjones", - RoleID: 1, - CompanyID: 1, - LocationID: 1, - Password: "newPass", - Base: model.Base{ - ID: 2, - }, - }, - }, - } - for _, tt := range cases { - t.Run(tt.name, func(t *testing.T) { - userBefore := &model.User{} - if tt.wantData != nil { - userBefore = queryUser(t, c, tt.usr.Base.ID) - } - err := db.Delete(c, tt.usr) - assert.Equal(t, tt.wantErr, err != nil) - - if tt.wantData != nil { - assert.NotEqual(t, tt.usr.DeletedAt, userBefore.DeletedAt) - tt.wantData.UpdatedAt = userBefore.UpdatedAt - tt.wantData.CreatedAt = userBefore.CreatedAt - tt.wantData.LastLogin = userBefore.LastLogin - assert.Equal(t, tt.wantData, userBefore) - } - }) - } -} - -func testUserUpdate(t *testing.T, db *pgsql.UserDB, c *pg.DB) { - cases := []struct { - name string - wantErr bool - usr *model.User - wantData *model.User - }{ - // Does not fail on this test, but should - // { - // name: "User does not exist", - // wantErr: true, - // usr: &model.User{}, - // }, - { - name: "Success", - usr: &model.User{ - Base: model.Base{ - ID: 2, - }, - FirstName: "Z", - LastName: "Freak", - Address: "Address", - Phone: "123456", - Mobile: "345678", - Username: "newUsername", - }, - // Expected wantData: - // wantData: &model.User{ - // Email: "tomjones@mail.com", - // FirstName: "Z", - // LastName: "Freak", - // Username: "tomjones", - // RoleID: 1, - // CompanyID: 1, - // LocationID: 1, - // Password: "newPass", - // Address: "Address", - // Phone: "123456", - // Mobile: "345678", - // Base: model.Base{ - // ID: 2, - // }, - // }, - wantData: &model.User{ - FirstName: "Z", - LastName: "Freak", - Username: "newUsername", - Address: "Address", - Phone: "123456", - Mobile: "345678", - Base: model.Base{ - ID: 2, - }, - }, - }, - } - for _, tt := range cases { - t.Run(tt.name, func(t *testing.T) { - resp, err := db.Update(c, tt.usr) - assert.Equal(t, tt.wantErr, err != nil) - if tt.wantData != nil { - tt.wantData.UpdatedAt = resp.UpdatedAt - tt.wantData.CreatedAt = resp.CreatedAt - tt.wantData.LastLogin = resp.LastLogin - tt.wantData.DeletedAt = resp.DeletedAt - assert.Equal(t, tt.wantData, resp) - } - }) - } -} diff --git a/internal/platform/query/query.go b/internal/platform/query/query.go deleted file mode 100644 index 6c35fc0..0000000 --- a/internal/platform/query/query.go +++ /dev/null @@ -1,20 +0,0 @@ -package query - -import ( - "github.com/labstack/echo" - "github.com/ribice/gorsk/internal" -) - -// List prepares data for list queries -func List(u *model.AuthUser) (*model.ListQuery, error) { - switch true { - case int(u.Role) <= 2: // user is SuperAdmin or Admin - return nil, nil - case u.Role == model.CompanyAdminRole: - return &model.ListQuery{Query: "company_id = ?", ID: u.CompanyID}, nil - case u.Role == model.LocationAdminRole: - return &model.ListQuery{Query: "location_id = ?", ID: u.LocationID}, nil - default: - return nil, echo.ErrForbidden - } -} diff --git a/internal/user.go b/internal/user.go deleted file mode 100644 index 5728bd8..0000000 --- a/internal/user.go +++ /dev/null @@ -1,61 +0,0 @@ -package model - -import ( - "time" - - "github.com/go-pg/pg/orm" -) - -// User represents user domain model -type User struct { - Base - FirstName string `json:"first_name"` - LastName string `json:"last_name"` - Username string `json:"username"` - Password string `json:"-"` - Email string `json:"email"` - Mobile string `json:"mobile,omitempty"` - Phone string `json:"phone,omitempty"` - Address string `json:"address,omitempty"` - LastLogin *time.Time `json:"last_login,omitempty"` - Active bool `json:"active"` - Token string `json:"-"` - - Role *Role `json:"role,omitempty"` - - RoleID AccessRole `json:"-"` - CompanyID int `json:"company_id"` - LocationID int `json:"location_id"` -} - -// AuthUser represents data stored in JWT token for user -type AuthUser struct { - ID int - CompanyID int - LocationID int - Username string - Email string - Role AccessRole -} - -// UpdateLastLogin updates last login field -func (u *User) UpdateLastLogin() { - t := time.Now() - u.LastLogin = &t -} - -// AccountDB represents account related database interface (repository) -type AccountDB interface { - Create(orm.DB, User) (*User, error) - ChangePassword(orm.DB, *User) error -} - -// UserDB represents user database interface (repository) -type UserDB interface { - View(orm.DB, int) (*User, error) - FindByUsername(orm.DB, string) (*User, error) - FindByToken(orm.DB, string) (*User, error) - List(orm.DB, *ListQuery, *Pagination) ([]User, error) - Delete(orm.DB, *User) error - Update(orm.DB, *User) (*User, error) -} diff --git a/internal/user/user.go b/internal/user/user.go deleted file mode 100644 index e973893..0000000 --- a/internal/user/user.go +++ /dev/null @@ -1,77 +0,0 @@ -// Package user contains user application services -package user - -import ( - "github.com/go-pg/pg" - "github.com/labstack/echo" - "github.com/ribice/gorsk/internal" - - "github.com/ribice/gorsk/internal/platform/query" - "github.com/ribice/gorsk/internal/platform/structs" -) - -// New creates new user application service -func New(db *pg.DB, udb model.UserDB, rbac model.RBACService, auth model.AuthService) *Service { - return &Service{db: db, udb: udb, rbac: rbac, auth: auth} -} - -// Service represents user application service -type Service struct { - db *pg.DB - udb model.UserDB - rbac model.RBACService - auth model.AuthService -} - -// List returns list of users -func (s *Service) List(c echo.Context, p *model.Pagination) ([]model.User, error) { - u := s.auth.User(c) - q, err := query.List(u) - if err != nil { - return nil, err - } - return s.udb.List(s.db, q, p) -} - -// View returns single user -func (s *Service) View(c echo.Context, id int) (*model.User, error) { - if err := s.rbac.EnforceUser(c, id); err != nil { - return nil, err - } - return s.udb.View(s.db, id) -} - -// Delete deletes a user -func (s *Service) Delete(c echo.Context, id int) error { - u, err := s.udb.View(s.db, id) - if err != nil { - return err - } - if err := s.rbac.IsLowerRole(c, u.Role.AccessLevel); err != nil { - return err - } - return s.udb.Delete(s.db, u) -} - -// Update contains user's information used for updating -type Update struct { - ID int - FirstName *string - LastName *string - Mobile *string - Phone *string - Address *string -} - -// Update updates user's contact information -func (s *Service) Update(c echo.Context, u *Update) (*model.User, error) { - if err := s.rbac.EnforceUser(c, u.ID); err != nil { - return nil, err - } - usr, err := s.udb.View(s.db, u.ID) - if err != nil { - return nil, err - } - structs.Merge(usr, u) - return s.udb.Update(s.db, usr) -} diff --git a/internal/user_test.go b/internal/user_test.go deleted file mode 100644 index 41a7b34..0000000 --- a/internal/user_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package model_test - -import ( - "testing" - - "github.com/ribice/gorsk/internal" -) - -func TestUpdateLastLogin(t *testing.T) { - user := &model.User{ - FirstName: "TestGuy", - } - user.UpdateLastLogin() - if user.LastLogin.IsZero() { - t.Errorf("Last login time was not changed") - } -} diff --git a/pkg/api/api.go b/pkg/api/api.go new file mode 100644 index 0000000..7daa21c --- /dev/null +++ b/pkg/api/api.go @@ -0,0 +1,83 @@ +// Copyright 2017 Emir Ribic. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +// GORSK - Go(lang) restful starter kit +// +// API Docs for GORSK v1 +// +// Terms Of Service: N/A +// Schemes: http +// Version: 2.0.0 +// License: MIT http://opensource.org/licenses/MIT +// Contact: Emir Ribic https://ribice.ba +// Host: localhost:8080 +// +// Consumes: +// - application/json +// +// Produces: +// - application/json +// +// Security: +// - bearer: [] +// +// SecurityDefinitions: +// bearer: +// type: apiKey +// name: Authorization +// in: header +// +// swagger:meta + +package api + +import ( + "crypto/sha1" + + "github.com/ribice/gorsk/pkg/api/auth" + at "github.com/ribice/gorsk/pkg/api/auth/transport" + "github.com/ribice/gorsk/pkg/api/password" + pt "github.com/ribice/gorsk/pkg/api/password/transport" + "github.com/ribice/gorsk/pkg/api/user" + ut "github.com/ribice/gorsk/pkg/api/user/transport" + + "github.com/ribice/gorsk/pkg/utl/config" + "github.com/ribice/gorsk/pkg/utl/middleware/jwt" + "github.com/ribice/gorsk/pkg/utl/postgres" + "github.com/ribice/gorsk/pkg/utl/rbac" + "github.com/ribice/gorsk/pkg/utl/secure" + "github.com/ribice/gorsk/pkg/utl/server" +) + +// Start starts the API service +func Start(cfg *config.Configuration) error { + db, err := postgres.New(cfg.DB.PSN, cfg.DB.Timeout, cfg.DB.LogQueries) + if err != nil { + return err + } + + sec := secure.New(cfg.App.MinPasswordStr, sha1.New()) + rbac := rbac.New() + jwt := jwt.New(cfg.JWT.Secret, cfg.JWT.SigningAlgorithm, cfg.JWT.Duration) + + e := server.New() + e.Static("/swaggerui", cfg.App.SwaggerUIPath) + + at.NewHTTP(auth.Initialize(db, jwt, sec, rbac), e, jwt.MWFunc()) + + v1 := e.Group("/v1") + v1.Use(jwt.MWFunc()) + + ut.NewHTTP(user.Initialize(db, rbac, sec), v1) + pt.NewHTTP(password.Initialize(db, rbac, sec), v1) + + server.Start(e, &server.Config{ + Port: cfg.Server.Port, + ReadTimeoutSeconds: cfg.Server.ReadTimeout, + WriteTimeoutSeconds: cfg.Server.WriteTimeout, + Debug: cfg.Server.Debug, + }) + + return nil +} diff --git a/pkg/api/auth/auth.go b/pkg/api/auth/auth.go new file mode 100644 index 0000000..2615e2a --- /dev/null +++ b/pkg/api/auth/auth.go @@ -0,0 +1,62 @@ +package auth + +import ( + "net/http" + + "github.com/ribice/gorsk/pkg/utl/model" + + "github.com/labstack/echo" +) + +// Custom errors +var ( + ErrInvalidCredentials = echo.NewHTTPError(http.StatusUnauthorized, "Username or password does not exist") +) + +// Authenticate tries to authenticate the user provided by username and password +func (a *Auth) Authenticate(c echo.Context, user, pass string) (*gorsk.AuthToken, error) { + u, err := a.udb.FindByUsername(a.db, user) + if err != nil { + return nil, err + } + + if !a.sec.HashMatchesPassword(u.Password, pass) { + return nil, ErrInvalidCredentials + } + + if !u.Active { + return nil, gorsk.ErrUnauthorized + } + + token, expire, err := a.tg.GenerateToken(u) + if err != nil { + return nil, gorsk.ErrUnauthorized + } + + u.UpdateLastLogin(a.sec.Token(token)) + + if err := a.udb.Update(a.db, u); err != nil { + return nil, err + } + + return &gorsk.AuthToken{Token: token, Expires: expire, RefreshToken: u.Token}, nil +} + +// Refresh refreshes jwt token and puts new claims inside +func (a *Auth) Refresh(c echo.Context, token string) (*gorsk.RefreshToken, error) { + user, err := a.udb.FindByToken(a.db, token) + if err != nil { + return nil, err + } + token, expire, err := a.tg.GenerateToken(user) + if err != nil { + return nil, err + } + return &gorsk.RefreshToken{Token: token, Expires: expire}, nil +} + +// Me returns info about currently logged user +func (a *Auth) Me(c echo.Context) (*gorsk.User, error) { + au := a.rbac.User(c) + return a.udb.View(a.db, au.ID) +} diff --git a/internal/auth/auth_test.go b/pkg/api/auth/auth_test.go similarity index 53% rename from internal/auth/auth_test.go rename to pkg/api/auth/auth_test.go index 81aeb02..3418ef4 100644 --- a/internal/auth/auth_test.go +++ b/pkg/api/auth/auth_test.go @@ -4,82 +4,98 @@ import ( "testing" "time" + "github.com/ribice/gorsk/pkg/api/auth" + "github.com/ribice/gorsk/pkg/utl/mock" + "github.com/ribice/gorsk/pkg/utl/mock/mockdb" + "github.com/ribice/gorsk/pkg/utl/model" + "github.com/go-pg/pg/orm" "github.com/labstack/echo" - "github.com/ribice/gorsk/internal" - "github.com/ribice/gorsk/internal/auth" - "github.com/ribice/gorsk/internal/mock" - "github.com/ribice/gorsk/internal/mock/mockdb" + "github.com/stretchr/testify/assert" ) func TestAuthenticate(t *testing.T) { type args struct { - c echo.Context user string pass string } cases := []struct { name string args args - wantData *model.AuthToken + wantData *gorsk.AuthToken wantErr bool udb *mockdb.User jwt *mock.JWT + sec *mock.Secure }{ { name: "Fail on finding user", args: args{user: "juzernejm"}, wantErr: true, udb: &mockdb.User{ - FindByUsernameFn: func(db orm.DB, user string) (*model.User, error) { - return nil, model.ErrGeneric + FindByUsernameFn: func(db orm.DB, user string) (*gorsk.User, error) { + return nil, gorsk.ErrGeneric }, }, }, { - name: "Fail on hashing", + name: "Fail on wrong password", args: args{user: "juzernejm", pass: "notHashedPassword"}, wantErr: true, udb: &mockdb.User{ - FindByUsernameFn: func(db orm.DB, user string) (*model.User, error) { - return &model.User{ + FindByUsernameFn: func(db orm.DB, user string) (*gorsk.User, error) { + return &gorsk.User{ Username: user, - Password: "HashedPassword", }, nil }, }, + sec: &mock.Secure{ + HashMatchesPasswordFn: func(string, string) bool { + return false + }, + }, }, { name: "Inactive user", args: args{user: "juzernejm", pass: "pass"}, wantErr: true, udb: &mockdb.User{ - FindByUsernameFn: func(db orm.DB, user string) (*model.User, error) { - return &model.User{ + FindByUsernameFn: func(db orm.DB, user string) (*gorsk.User, error) { + return &gorsk.User{ Username: user, - Password: auth.HashPassword("pass"), + Password: "pass", Active: false, }, nil }, }, + sec: &mock.Secure{ + HashMatchesPasswordFn: func(string, string) bool { + return true + }, + }, }, { name: "Fail on token generation", args: args{user: "juzernejm", pass: "pass"}, wantErr: true, udb: &mockdb.User{ - FindByUsernameFn: func(db orm.DB, user string) (*model.User, error) { - return &model.User{ + FindByUsernameFn: func(db orm.DB, user string) (*gorsk.User, error) { + return &gorsk.User{ Username: user, - Password: auth.HashPassword("pass"), + Password: "pass", Active: true, }, nil }, }, + sec: &mock.Secure{ + HashMatchesPasswordFn: func(string, string) bool { + return true + }, + }, jwt: &mock.JWT{ - GenerateTokenFn: func(u *model.User) (string, string, error) { - return "", "", model.ErrGeneric + GenerateTokenFn: func(u *gorsk.User) (string, string, error) { + return "", "", gorsk.ErrGeneric }, }, }, @@ -88,19 +104,27 @@ func TestAuthenticate(t *testing.T) { args: args{user: "juzernejm", pass: "pass"}, wantErr: true, udb: &mockdb.User{ - FindByUsernameFn: func(db orm.DB, user string) (*model.User, error) { - return &model.User{ + FindByUsernameFn: func(db orm.DB, user string) (*gorsk.User, error) { + return &gorsk.User{ Username: user, - Password: auth.HashPassword("pass"), + Password: "pass", Active: true, }, nil }, - UpdateFn: func(db orm.DB, u *model.User) (*model.User, error) { - return nil, model.ErrGeneric + UpdateFn: func(db orm.DB, u *gorsk.User) error { + return gorsk.ErrGeneric + }, + }, + sec: &mock.Secure{ + HashMatchesPasswordFn: func(string, string) bool { + return true + }, + TokenFn: func(string) string { + return "refreshtoken" }, }, jwt: &mock.JWT{ - GenerateTokenFn: func(u *model.User) (string, string, error) { + GenerateTokenFn: func(u *gorsk.User) (string, string, error) { return "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9", mock.TestTime(2000).Format(time.RFC3339), nil }, }, @@ -109,32 +133,41 @@ func TestAuthenticate(t *testing.T) { name: "Success", args: args{user: "juzernejm", pass: "pass"}, udb: &mockdb.User{ - FindByUsernameFn: func(db orm.DB, user string) (*model.User, error) { - return &model.User{ + FindByUsernameFn: func(db orm.DB, user string) (*gorsk.User, error) { + return &gorsk.User{ Username: user, - Password: auth.HashPassword("pass"), + Password: "password", Active: true, }, nil }, - UpdateFn: func(db orm.DB, u *model.User) (*model.User, error) { - return u, nil + UpdateFn: func(db orm.DB, u *gorsk.User) error { + return nil }, }, jwt: &mock.JWT{ - GenerateTokenFn: func(u *model.User) (string, string, error) { + GenerateTokenFn: func(u *gorsk.User) (string, string, error) { return "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9", mock.TestTime(2000).Format(time.RFC3339), nil }, }, - wantData: &model.AuthToken{ - Token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9", - Expires: mock.TestTime(2000).Format(time.RFC3339), + sec: &mock.Secure{ + HashMatchesPasswordFn: func(string, string) bool { + return true + }, + TokenFn: func(string) string { + return "refreshtoken" + }, + }, + wantData: &gorsk.AuthToken{ + Token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9", + Expires: mock.TestTime(2000).Format(time.RFC3339), + RefreshToken: "refreshtoken", }, }, } for _, tt := range cases { t.Run(tt.name, func(t *testing.T) { - s := auth.New(nil, tt.udb, tt.jwt) - token, err := s.Authenticate(tt.args.c, tt.args.user, tt.args.pass) + s := auth.New(nil, tt.udb, tt.jwt, tt.sec, nil) + token, err := s.Authenticate(nil, tt.args.user, tt.args.pass) if tt.wantData != nil { tt.wantData.RefreshToken = token.RefreshToken assert.Equal(t, tt.wantData, token) @@ -151,7 +184,7 @@ func TestRefresh(t *testing.T) { cases := []struct { name string args args - wantData *model.RefreshToken + wantData *gorsk.RefreshToken wantErr bool udb *mockdb.User jwt *mock.JWT @@ -161,8 +194,8 @@ func TestRefresh(t *testing.T) { args: args{token: "refreshtoken"}, wantErr: true, udb: &mockdb.User{ - FindByTokenFn: func(db orm.DB, token string) (*model.User, error) { - return nil, model.ErrGeneric + FindByTokenFn: func(db orm.DB, token string) (*gorsk.User, error) { + return nil, gorsk.ErrGeneric }, }, }, @@ -171,8 +204,8 @@ func TestRefresh(t *testing.T) { args: args{token: "refreshtoken"}, wantErr: true, udb: &mockdb.User{ - FindByTokenFn: func(db orm.DB, token string) (*model.User, error) { - return &model.User{ + FindByTokenFn: func(db orm.DB, token string) (*gorsk.User, error) { + return &gorsk.User{ Username: "username", Password: "password", Active: true, @@ -181,8 +214,8 @@ func TestRefresh(t *testing.T) { }, }, jwt: &mock.JWT{ - GenerateTokenFn: func(u *model.User) (string, string, error) { - return "", "", model.ErrGeneric + GenerateTokenFn: func(u *gorsk.User) (string, string, error) { + return "", "", gorsk.ErrGeneric }, }, }, @@ -190,8 +223,8 @@ func TestRefresh(t *testing.T) { name: "Success", args: args{token: "refreshtoken"}, udb: &mockdb.User{ - FindByTokenFn: func(db orm.DB, token string) (*model.User, error) { - return &model.User{ + FindByTokenFn: func(db orm.DB, token string) (*gorsk.User, error) { + return &gorsk.User{ Username: "username", Password: "password", Active: true, @@ -200,11 +233,11 @@ func TestRefresh(t *testing.T) { }, }, jwt: &mock.JWT{ - GenerateTokenFn: func(u *model.User) (string, string, error) { + GenerateTokenFn: func(u *gorsk.User) (string, string, error) { return "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9", mock.TestTime(2000).Format(time.RFC3339), nil }, }, - wantData: &model.RefreshToken{ + wantData: &gorsk.RefreshToken{ Token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9", Expires: mock.TestTime(2000).Format(time.RFC3339), }, @@ -212,93 +245,72 @@ func TestRefresh(t *testing.T) { } for _, tt := range cases { t.Run(tt.name, func(t *testing.T) { - s := auth.New(nil, tt.udb, tt.jwt) + s := auth.New(nil, tt.udb, tt.jwt, nil, nil) token, err := s.Refresh(tt.args.c, tt.args.token) assert.Equal(t, tt.wantData, token) assert.Equal(t, tt.wantErr, err != nil) }) } } -func TestUser(t *testing.T) { - ctx := mock.EchoCtxWithKeys([]string{ - "id", "company_id", "location_id", "username", "email", "role"}, - 9, 15, 52, "ribice", "ribice@gmail.com", model.AccessRole(1)) - wantUser := &model.AuthUser{ - ID: 9, - Username: "ribice", - CompanyID: 15, - LocationID: 52, - Email: "ribice@gmail.com", - Role: model.SuperAdminRole, - } - rbacSvc := auth.New(nil, nil, nil) - assert.Equal(t, wantUser, rbacSvc.User(ctx)) -} - -func TestHashPassowrd(t *testing.T) { - password := "Hunter123" - hash := auth.HashPassword(password) - if password == hash { - t.Error("Passsword and hash should not be equal") - } -} - -func TestHashMatchesPassword(t *testing.T) { - password := "Hunter123" - if !auth.HashMatchesPassword(auth.HashPassword(password), password) { - t.Error("Passsword and hash should match") - } -} func TestMe(t *testing.T) { cases := []struct { name string - ctx echo.Context - wantData *model.User + wantData *gorsk.User udb *mockdb.User + rbac *mock.RBAC wantErr bool }{ { name: "Success", - ctx: mock.EchoCtxWithKeys([]string{ - "id", "company_id", "location_id", "username", "email", "role"}, - 9, 15, 52, "ribice", "ribice@gmail.com", model.AccessRole(1)), + rbac: &mock.RBAC{ + UserFn: func(echo.Context) *gorsk.AuthUser { + return &gorsk.AuthUser{ID: 9} + }, + }, udb: &mockdb.User{ - ViewFn: func(db orm.DB, id int) (*model.User, error) { - return &model.User{ - Base: model.Base{ + ViewFn: func(db orm.DB, id int) (*gorsk.User, error) { + return &gorsk.User{ + Base: gorsk.Base{ ID: id, CreatedAt: mock.TestTime(1999), UpdatedAt: mock.TestTime(2000), }, FirstName: "John", LastName: "Doe", - Role: &model.Role{ - AccessLevel: model.UserRole, + Role: &gorsk.Role{ + AccessLevel: gorsk.UserRole, }, }, nil }, }, - wantData: &model.User{ - Base: model.Base{ + wantData: &gorsk.User{ + Base: gorsk.Base{ ID: 9, CreatedAt: mock.TestTime(1999), UpdatedAt: mock.TestTime(2000), }, FirstName: "John", LastName: "Doe", - Role: &model.Role{ - AccessLevel: model.UserRole, + Role: &gorsk.Role{ + AccessLevel: gorsk.UserRole, }, }, }, } for _, tt := range cases { t.Run(tt.name, func(t *testing.T) { - s := auth.New(nil, tt.udb, nil) - user, err := s.Me(tt.ctx) + s := auth.New(nil, tt.udb, nil, nil, tt.rbac) + user, err := s.Me(nil) assert.Equal(t, tt.wantData, user) assert.Equal(t, tt.wantErr, err != nil) }) } } + +func TestInitialize(t *testing.T) { + a := auth.Initialize(nil, nil, nil, nil) + if a == nil { + t.Error("auth service not initialized") + } +} diff --git a/pkg/api/auth/platform/pgsql/user.go b/pkg/api/auth/platform/pgsql/user.go new file mode 100644 index 0000000..b383bb0 --- /dev/null +++ b/pkg/api/auth/platform/pgsql/user.go @@ -0,0 +1,57 @@ +package pgsql + +import ( + "github.com/go-pg/pg/orm" + "github.com/ribice/gorsk/pkg/utl/model" +) + +// NewUser returns a new user database instance +func NewUser() *User { + return &User{} +} + +// User represents the client for user table +type User struct{} + +// View returns single user by ID +func (u *User) View(db orm.DB, id int) (*gorsk.User, error) { + var user = new(gorsk.User) + sql := `SELECT "user".*, "role"."id" AS "role__id", "role"."access_level" AS "role__access_level", "role"."name" AS "role__name" + FROM "users" AS "user" LEFT JOIN "roles" AS "role" ON "role"."id" = "user"."role_id" + WHERE ("user"."id" = ? and deleted_at is null)` + _, err := db.QueryOne(user, sql, id) + if err != nil { + return nil, err + } + return user, nil +} + +// FindByUsername queries for single user by username +func (u *User) FindByUsername(db orm.DB, uname string) (*gorsk.User, error) { + var user = new(gorsk.User) + sql := `SELECT "user".*, "role"."id" AS "role__id", "role"."access_level" AS "role__access_level", "role"."name" AS "role__name" + FROM "users" AS "user" LEFT JOIN "roles" AS "role" ON "role"."id" = "user"."role_id" + WHERE ("user"."username" = ? and deleted_at is null)` + _, err := db.QueryOne(user, sql, uname) + if err != nil { + return nil, err + } + return user, nil +} + +// FindByToken queries for single user by token +func (u *User) FindByToken(db orm.DB, token string) (*gorsk.User, error) { + var user = new(gorsk.User) + sql := `SELECT "user".*, "role"."id" AS "role__id", "role"."access_level" AS "role__access_level", "role"."name" AS "role__name" + FROM "users" AS "user" LEFT JOIN "roles" AS "role" ON "role"."id" = "user"."role_id" + WHERE ("user"."token" = ? and deleted_at is null)` + _, err := db.QueryOne(user, sql, token) + if err != nil { + } + return user, err +} + +// Update updates user's info +func (u *User) Update(db orm.DB, user *gorsk.User) error { + return db.Update(user) +} diff --git a/pkg/api/auth/platform/pgsql/user_test.go b/pkg/api/auth/platform/pgsql/user_test.go new file mode 100644 index 0000000..faa7d9e --- /dev/null +++ b/pkg/api/auth/platform/pgsql/user_test.go @@ -0,0 +1,287 @@ +package pgsql_test + +import ( + "testing" + + "github.com/ribice/gorsk/pkg/utl/model" + + "github.com/ribice/gorsk/pkg/utl/mock" + + "github.com/ribice/gorsk/pkg/api/auth/platform/pgsql" + + "github.com/stretchr/testify/assert" +) + +func TestView(t *testing.T) { + cases := []struct { + name string + wantErr bool + id int + wantData *gorsk.User + }{ + { + name: "User does not exist", + wantErr: true, + id: 1000, + }, + { + name: "Success", + id: 2, + wantData: &gorsk.User{ + Email: "tomjones@mail.com", + FirstName: "Tom", + LastName: "Jones", + Username: "tomjones", + RoleID: 1, + CompanyID: 1, + LocationID: 1, + Password: "newPass", + Base: gorsk.Base{ + ID: 2, + }, + Role: &gorsk.Role{ + ID: 1, + AccessLevel: 1, + Name: "SUPER_ADMIN", + }, + }, + }, + } + + dbCon := mock.NewPGContainer(t) + defer dbCon.Shutdown() + + db := mock.NewDB(t, dbCon, &gorsk.Role{}, &gorsk.User{}) + + if err := mock.InsertMultiple(db, &gorsk.Role{ + ID: 1, + AccessLevel: 1, + Name: "SUPER_ADMIN"}, cases[1].wantData); err != nil { + t.Error(err) + } + + udb := pgsql.NewUser() + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + user, err := udb.View(db, tt.id) + assert.Equal(t, tt.wantErr, err != nil) + if tt.wantData != nil { + if user == nil { + t.Errorf("response was nil due to: %v", err) + } else { + tt.wantData.CreatedAt = user.CreatedAt + tt.wantData.UpdatedAt = user.UpdatedAt + assert.Equal(t, tt.wantData, user) + } + } + }) + } +} + +func TestFindByUsername(t *testing.T) { + cases := []struct { + name string + wantErr bool + username string + wantData *gorsk.User + }{ + { + name: "User does not exist", + wantErr: true, + username: "notExists", + }, + { + name: "Success", + username: "tomjones", + wantData: &gorsk.User{ + Email: "tomjones@mail.com", + FirstName: "Tom", + LastName: "Jones", + Username: "tomjones", + RoleID: 1, + CompanyID: 1, + LocationID: 1, + Password: "newPass", + Base: gorsk.Base{ + ID: 2, + }, + Role: &gorsk.Role{ + ID: 1, + AccessLevel: 1, + Name: "SUPER_ADMIN", + }, + }, + }, + } + + dbCon := mock.NewPGContainer(t) + defer dbCon.Shutdown() + + db := mock.NewDB(t, dbCon, &gorsk.Role{}, &gorsk.User{}) + + if err := mock.InsertMultiple(db, &gorsk.Role{ + ID: 1, + AccessLevel: 1, + Name: "SUPER_ADMIN"}, cases[1].wantData); err != nil { + t.Error(err) + } + + udb := pgsql.NewUser() + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + user, err := udb.FindByUsername(db, tt.username) + assert.Equal(t, tt.wantErr, err != nil) + + if tt.wantData != nil { + tt.wantData.CreatedAt = user.CreatedAt + tt.wantData.UpdatedAt = user.UpdatedAt + assert.Equal(t, tt.wantData, user) + + } + }) + } +} + +func TestFindByToken(t *testing.T) { + cases := []struct { + name string + wantErr bool + token string + wantData *gorsk.User + }{ + { + name: "User does not exist", + wantErr: true, + token: "notExists", + }, + { + name: "Success", + token: "loginrefresh", + wantData: &gorsk.User{ + Email: "johndoe@mail.com", + FirstName: "John", + LastName: "Doe", + Username: "johndoe", + RoleID: 1, + CompanyID: 1, + LocationID: 1, + Password: "hunter2", + Base: gorsk.Base{ + ID: 1, + }, + Role: &gorsk.Role{ + ID: 1, + AccessLevel: 1, + Name: "SUPER_ADMIN", + }, + Token: "loginrefresh", + }, + }, + } + + dbCon := mock.NewPGContainer(t) + defer dbCon.Shutdown() + + db := mock.NewDB(t, dbCon, &gorsk.Role{}, &gorsk.User{}) + + if err := mock.InsertMultiple(db, &gorsk.Role{ + ID: 1, + AccessLevel: 1, + Name: "SUPER_ADMIN"}, cases[1].wantData); err != nil { + t.Error(err) + } + + udb := pgsql.NewUser() + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + user, err := udb.FindByToken(db, tt.token) + assert.Equal(t, tt.wantErr, err != nil) + + if tt.wantData != nil { + tt.wantData.CreatedAt = user.CreatedAt + tt.wantData.UpdatedAt = user.UpdatedAt + assert.Equal(t, tt.wantData, user) + + } + }) + } +} + +func TestUpdate(t *testing.T) { + cases := []struct { + name string + wantErr bool + usr *gorsk.User + wantData *gorsk.User + }{ + { + name: "Success", + usr: &gorsk.User{ + Base: gorsk.Base{ + ID: 2, + }, + FirstName: "Z", + LastName: "Freak", + Address: "Address", + Phone: "123456", + Mobile: "345678", + Username: "newUsername", + }, + wantData: &gorsk.User{ + Email: "tomjones@mail.com", + FirstName: "Z", + LastName: "Freak", + Username: "tomjones", + RoleID: 1, + CompanyID: 1, + LocationID: 1, + Password: "newPass", + Address: "Address", + Phone: "123456", + Mobile: "345678", + Base: gorsk.Base{ + ID: 2, + }, + }, + }, + } + + dbCon := mock.NewPGContainer(t) + defer dbCon.Shutdown() + + db := mock.NewDB(t, dbCon, &gorsk.Role{}, &gorsk.User{}) + + if err := mock.InsertMultiple(db, &gorsk.Role{ + ID: 1, + AccessLevel: 1, + Name: "SUPER_ADMIN"}, cases[0].usr); err != nil { + t.Error(err) + } + + udb := pgsql.NewUser() + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + err := udb.Update(db, tt.wantData) + assert.Equal(t, tt.wantErr, err != nil) + if tt.wantData != nil { + user := &gorsk.User{ + Base: gorsk.Base{ + ID: tt.usr.ID, + }, + } + if err := db.Select(user); err != nil { + t.Error(err) + } + tt.wantData.UpdatedAt = user.UpdatedAt + tt.wantData.CreatedAt = user.CreatedAt + tt.wantData.LastLogin = user.LastLogin + tt.wantData.DeletedAt = user.DeletedAt + assert.Equal(t, tt.wantData, user) + } + }) + } +} diff --git a/pkg/api/auth/service.go b/pkg/api/auth/service.go new file mode 100644 index 0000000..b742aad --- /dev/null +++ b/pkg/api/auth/service.go @@ -0,0 +1,65 @@ +package auth + +import ( + "github.com/go-pg/pg" + "github.com/go-pg/pg/orm" + "github.com/labstack/echo" + "github.com/ribice/gorsk/pkg/api/auth/platform/pgsql" + "github.com/ribice/gorsk/pkg/utl/model" +) + +// New creates new iam service +func New(db *pg.DB, udb UserDB, j TokenGenerator, sec Securer, rbac RBAC) *Auth { + return &Auth{ + db: db, + udb: udb, + tg: j, + sec: sec, + rbac: rbac, + } +} + +// Initialize initializes auth application service +func Initialize(db *pg.DB, j TokenGenerator, sec Securer, rbac RBAC) *Auth { + return New(db, pgsql.NewUser(), j, sec, rbac) +} + +// Service represents auth service interface +type Service interface { + Authenticate(echo.Context, string, string) (*gorsk.AuthToken, error) + Refresh(echo.Context, string) (*gorsk.RefreshToken, error) + Me(echo.Context) (*gorsk.User, error) +} + +// Auth represents auth application service +type Auth struct { + db *pg.DB + udb UserDB + tg TokenGenerator + sec Securer + rbac RBAC +} + +// UserDB represents user repository interface +type UserDB interface { + View(orm.DB, int) (*gorsk.User, error) + FindByUsername(orm.DB, string) (*gorsk.User, error) + FindByToken(orm.DB, string) (*gorsk.User, error) + Update(orm.DB, *gorsk.User) error +} + +// TokenGenerator represents token generator (jwt) interface +type TokenGenerator interface { + GenerateToken(*gorsk.User) (string, string, error) +} + +// Securer represents security interface +type Securer interface { + HashMatchesPassword(string, string) bool + Token(string) string +} + +// RBAC represents role-based-access-control interface +type RBAC interface { + User(echo.Context) *gorsk.AuthUser +} diff --git a/cmd/api/service/auth.go b/pkg/api/auth/transport/http.go similarity index 57% rename from cmd/api/service/auth.go rename to pkg/api/auth/transport/http.go index 08cb248..c236f85 100644 --- a/cmd/api/service/auth.go +++ b/pkg/api/auth/transport/http.go @@ -1,22 +1,21 @@ -package service +package transport import ( "net/http" - "github.com/labstack/echo" - "github.com/ribice/gorsk/cmd/api/request" + "github.com/ribice/gorsk/pkg/api/auth" - "github.com/ribice/gorsk/internal/auth" + "github.com/labstack/echo" ) -// Auth represents auth http service -type Auth struct { - svc *auth.Service +// HTTP represents auth http service +type HTTP struct { + svc auth.Service } -// NewAuth creates new auth http service -func NewAuth(svc *auth.Service, e *echo.Echo, mw echo.MiddlewareFunc) { - a := Auth{svc} +// NewHTTP creates new auth http service +func NewHTTP(svc auth.Service, e *echo.Echo, mw echo.MiddlewareFunc) { + h := HTTP{svc} // swagger:route POST /login auth login // Logs in user by username and password. // responses: @@ -26,7 +25,7 @@ func NewAuth(svc *auth.Service, e *echo.Echo, mw echo.MiddlewareFunc) { // 403: err // 404: errMsg // 500: err - e.POST("/login", a.login) + e.POST("/login", h.login) // swagger:operation GET /refresh/{token} auth refresh // --- // summary: Refreshes jwt token. @@ -46,38 +45,43 @@ func NewAuth(svc *auth.Service, e *echo.Echo, mw echo.MiddlewareFunc) { // "$ref": "#/responses/err" // "500": // "$ref": "#/responses/err" - e.GET("/refresh/:token", a.refresh) + e.GET("/refresh/:token", h.refresh) // swagger:route GET /me auth meReq // Gets user's info from session. // responses: // 200: userResp // 500: err - e.GET("/me", a.me, mw) + e.GET("/me", h.me, mw) } -func (a *Auth) login(c echo.Context) error { - cred, err := request.Login(c) - if err != nil { +type credentials struct { + Username string `json:"username" validate:"required"` + Password string `json:"password" validate:"required"` +} + +func (h *HTTP) login(c echo.Context) error { + cred := new(credentials) + if err := c.Bind(cred); err != nil { return err } - r, err := a.svc.Authenticate(c, cred.Username, cred.Password) + r, err := h.svc.Authenticate(c, cred.Username, cred.Password) if err != nil { return err } return c.JSON(http.StatusOK, r) } -func (a *Auth) refresh(c echo.Context) error { - r, err := a.svc.Refresh(c, c.Param("token")) +func (h *HTTP) refresh(c echo.Context) error { + r, err := h.svc.Refresh(c, c.Param("token")) if err != nil { return err } return c.JSON(http.StatusOK, r) } -func (a *Auth) me(c echo.Context) error { - user, err := a.svc.Me(c) +func (h *HTTP) me(c echo.Context) error { + user, err := h.svc.Me(c) if err != nil { return err } diff --git a/cmd/api/service/auth_test.go b/pkg/api/auth/transport/http_test.go similarity index 63% rename from cmd/api/service/auth_test.go rename to pkg/api/auth/transport/http_test.go index ab0437c..9bc88d2 100644 --- a/cmd/api/service/auth_test.go +++ b/pkg/api/auth/transport/http_test.go @@ -1,4 +1,4 @@ -package service_test +package transport_test import ( "bytes" @@ -8,17 +8,18 @@ import ( "testing" "time" + "github.com/labstack/echo" + + "github.com/ribice/gorsk/pkg/api/auth" + "github.com/ribice/gorsk/pkg/api/auth/transport" + "github.com/ribice/gorsk/pkg/utl/middleware/jwt" + "github.com/ribice/gorsk/pkg/utl/mock" + "github.com/ribice/gorsk/pkg/utl/mock/mockdb" + "github.com/ribice/gorsk/pkg/utl/model" + "github.com/ribice/gorsk/pkg/utl/server" + "github.com/go-pg/pg/orm" - "github.com/ribice/gorsk/internal" "github.com/stretchr/testify/assert" - - "github.com/ribice/gorsk/cmd/api/config" - "github.com/ribice/gorsk/cmd/api/mw" - "github.com/ribice/gorsk/cmd/api/server" - "github.com/ribice/gorsk/cmd/api/service" - "github.com/ribice/gorsk/internal/auth" - "github.com/ribice/gorsk/internal/mock" - "github.com/ribice/gorsk/internal/mock/mockdb" ) func TestLogin(t *testing.T) { @@ -26,9 +27,10 @@ func TestLogin(t *testing.T) { name string req string wantStatus int - wantResp *model.AuthToken + wantResp *gorsk.AuthToken udb *mockdb.User jwt *mock.JWT + sec *mock.Secure }{ { name: "Invalid request", @@ -40,8 +42,8 @@ func TestLogin(t *testing.T) { req: `{"username":"juzernejm","password":"hunter123"}`, wantStatus: http.StatusInternalServerError, udb: &mockdb.User{ - FindByUsernameFn: func(orm.DB, string) (*model.User, error) { - return nil, model.ErrGeneric + FindByUsernameFn: func(orm.DB, string) (*gorsk.User, error) { + return nil, gorsk.ErrGeneric }, }, }, @@ -50,29 +52,37 @@ func TestLogin(t *testing.T) { req: `{"username":"juzernejm","password":"hunter123"}`, wantStatus: http.StatusOK, udb: &mockdb.User{ - FindByUsernameFn: func(orm.DB, string) (*model.User, error) { - return &model.User{ - Password: auth.HashPassword("hunter123"), + FindByUsernameFn: func(orm.DB, string) (*gorsk.User, error) { + return &gorsk.User{ + Password: "hunter123", Active: true, }, nil }, - UpdateFn: func(db orm.DB, u *model.User) (*model.User, error) { - return u, nil + UpdateFn: func(db orm.DB, u *gorsk.User) error { + return nil }, }, jwt: &mock.JWT{ - GenerateTokenFn: func(*model.User) (string, string, error) { + GenerateTokenFn: func(*gorsk.User) (string, string, error) { return "jwttokenstring", mock.TestTime(2018).Format(time.RFC3339), nil }, }, - wantResp: &model.AuthToken{Token: "jwttokenstring", Expires: mock.TestTime(2018).Format(time.RFC3339)}, + sec: &mock.Secure{ + HashMatchesPasswordFn: func(string, string) bool { + return true + }, + TokenFn: func(string) string { + return "refreshtoken" + }, + }, + wantResp: &gorsk.AuthToken{Token: "jwttokenstring", Expires: mock.TestTime(2018).Format(time.RFC3339), RefreshToken: "refreshtoken"}, }, } for _, tt := range cases { t.Run(tt.name, func(t *testing.T) { r := server.New() - service.NewAuth(auth.New(nil, tt.udb, tt.jwt), r, nil) + transport.NewHTTP(auth.New(nil, tt.udb, tt.jwt, tt.sec, nil), r, nil) ts := httptest.NewServer(r) defer ts.Close() path := ts.URL + "/login" @@ -82,7 +92,7 @@ func TestLogin(t *testing.T) { } defer res.Body.Close() if tt.wantResp != nil { - response := new(model.AuthToken) + response := new(gorsk.AuthToken) if err := json.NewDecoder(res.Body).Decode(response); err != nil { t.Fatal(err) } @@ -99,7 +109,7 @@ func TestRefresh(t *testing.T) { name string req string wantStatus int - wantResp *model.RefreshToken + wantResp *gorsk.RefreshToken udb *mockdb.User jwt *mock.JWT }{ @@ -108,8 +118,8 @@ func TestRefresh(t *testing.T) { req: "refreshtoken", wantStatus: http.StatusInternalServerError, udb: &mockdb.User{ - FindByTokenFn: func(orm.DB, string) (*model.User, error) { - return nil, model.ErrGeneric + FindByTokenFn: func(orm.DB, string) (*gorsk.User, error) { + return nil, gorsk.ErrGeneric }, }, }, @@ -118,26 +128,26 @@ func TestRefresh(t *testing.T) { req: "refreshtoken", wantStatus: http.StatusOK, udb: &mockdb.User{ - FindByTokenFn: func(orm.DB, string) (*model.User, error) { - return &model.User{ + FindByTokenFn: func(orm.DB, string) (*gorsk.User, error) { + return &gorsk.User{ Username: "johndoe", Active: true, }, nil }, }, jwt: &mock.JWT{ - GenerateTokenFn: func(*model.User) (string, string, error) { + GenerateTokenFn: func(*gorsk.User) (string, string, error) { return "jwttokenstring", mock.TestTime(2018).Format(time.RFC3339), nil }, }, - wantResp: &model.RefreshToken{Token: "jwttokenstring", Expires: mock.TestTime(2018).Format(time.RFC3339)}, + wantResp: &gorsk.RefreshToken{Token: "jwttokenstring", Expires: mock.TestTime(2018).Format(time.RFC3339)}, }, } for _, tt := range cases { t.Run(tt.name, func(t *testing.T) { r := server.New() - service.NewAuth(auth.New(nil, tt.udb, tt.jwt), r, nil) + transport.NewHTTP(auth.New(nil, tt.udb, tt.jwt, nil, nil), r, nil) ts := httptest.NewServer(r) defer ts.Close() path := ts.URL + "/refresh/" + tt.req @@ -147,7 +157,7 @@ func TestRefresh(t *testing.T) { } defer res.Body.Close() if tt.wantResp != nil { - response := new(model.RefreshToken) + response := new(gorsk.RefreshToken) if err := json.NewDecoder(res.Body).Decode(response); err != nil { t.Fatal(err) } @@ -162,16 +172,22 @@ func TestMe(t *testing.T) { cases := []struct { name string wantStatus int - wantResp *model.User + wantResp *gorsk.User header string udb *mockdb.User + rbac *mock.RBAC }{ { name: "Fail on user view", wantStatus: http.StatusInternalServerError, udb: &mockdb.User{ - ViewFn: func(orm.DB, int) (*model.User, error) { - return nil, model.ErrGeneric + ViewFn: func(orm.DB, int) (*gorsk.User, error) { + return nil, gorsk.ErrGeneric + }, + }, + rbac: &mock.RBAC{ + UserFn: func(echo.Context) *gorsk.AuthUser { + return &gorsk.AuthUser{ID: 1} }, }, header: mock.HeaderValid(), @@ -180,9 +196,9 @@ func TestMe(t *testing.T) { name: "Success", wantStatus: http.StatusOK, udb: &mockdb.User{ - ViewFn: func(db orm.DB, i int) (*model.User, error) { - return &model.User{ - Base: model.Base{ + ViewFn: func(db orm.DB, i int) (*gorsk.User, error) { + return &gorsk.User{ + Base: gorsk.Base{ ID: i, }, CompanyID: 2, @@ -193,9 +209,14 @@ func TestMe(t *testing.T) { }, nil }, }, + rbac: &mock.RBAC{ + UserFn: func(echo.Context) *gorsk.AuthUser { + return &gorsk.AuthUser{ID: 1} + }, + }, header: mock.HeaderValid(), - wantResp: &model.User{ - Base: model.Base{ + wantResp: &gorsk.User{ + Base: gorsk.Base{ ID: 1, }, CompanyID: 2, @@ -208,13 +229,12 @@ func TestMe(t *testing.T) { } client := &http.Client{} - jwtCfg := &config.JWT{Secret: "jwtsecret", Duration: 60, SigningAlgorithm: "HS256"} - jwtMW := mw.NewJWT(jwtCfg) + jwtMW := jwt.New("jwtsecret", "HS256", 60) for _, tt := range cases { t.Run(tt.name, func(t *testing.T) { r := server.New() - service.NewAuth(auth.New(nil, tt.udb, nil), r, jwtMW.MWFunc()) + transport.NewHTTP(auth.New(nil, tt.udb, nil, nil, tt.rbac), r, jwtMW.MWFunc()) ts := httptest.NewServer(r) defer ts.Close() path := ts.URL + "/me" @@ -229,7 +249,7 @@ func TestMe(t *testing.T) { } defer res.Body.Close() if tt.wantResp != nil { - response := new(model.User) + response := new(gorsk.User) if err := json.NewDecoder(res.Body).Decode(response); err != nil { t.Fatal(err) } diff --git a/cmd/api/swagger/auth.go b/pkg/api/auth/transport/swagger.go similarity index 67% rename from cmd/api/swagger/auth.go rename to pkg/api/auth/transport/swagger.go index 34872aa..4b198b2 100644 --- a/cmd/api/swagger/auth.go +++ b/pkg/api/auth/transport/swagger.go @@ -1,15 +1,14 @@ -package swagger +package transport import ( - "github.com/ribice/gorsk/cmd/api/request" - "github.com/ribice/gorsk/internal" + "github.com/ribice/gorsk/pkg/utl/model" ) // Login request // swagger:parameters login type swaggLoginReq struct { // in:body - Body request.Credentials + Body credentials } // Login response @@ -17,7 +16,7 @@ type swaggLoginReq struct { type swaggLoginResp struct { // in:body Body struct { - *model.AuthToken + *gorsk.AuthToken } } @@ -26,6 +25,6 @@ type swaggLoginResp struct { type swaggRefreshResp struct { // in:body Body struct { - *model.RefreshToken + *gorsk.RefreshToken } } diff --git a/pkg/api/password/password.go b/pkg/api/password/password.go new file mode 100644 index 0000000..edbffe0 --- /dev/null +++ b/pkg/api/password/password.go @@ -0,0 +1,37 @@ +package password + +import ( + "net/http" + + "github.com/labstack/echo" +) + +// Custom errors +var ( + ErrIncorrectPassword = echo.NewHTTPError(http.StatusBadRequest, "incorrect old password") + ErrInsecurePassword = echo.NewHTTPError(http.StatusBadRequest, "insecure password") +) + +// Change changes user's password +func (p *Password) Change(c echo.Context, userID int, oldPass, newPass string) error { + if err := p.rbac.EnforceUser(c, userID); err != nil { + return err + } + + u, err := p.udb.View(p.db, userID) + if err != nil { + return err + } + + if !p.sec.HashMatchesPassword(u.Password, oldPass) { + return ErrIncorrectPassword + } + + if !p.sec.Password(newPass, u.FirstName, u.LastName, u.Username, u.Email) { + return ErrInsecurePassword + } + + u.ChangePassword(p.sec.Hash(newPass)) + + return p.udb.Update(p.db, u) +} diff --git a/pkg/api/password/password_test.go b/pkg/api/password/password_test.go new file mode 100644 index 0000000..d1cc170 --- /dev/null +++ b/pkg/api/password/password_test.go @@ -0,0 +1,148 @@ +package password_test + +import ( + "testing" + + "github.com/ribice/gorsk/pkg/api/password" + + "github.com/ribice/gorsk/pkg/utl/mock" + "github.com/ribice/gorsk/pkg/utl/mock/mockdb" + "github.com/ribice/gorsk/pkg/utl/model" + + "github.com/go-pg/pg/orm" + "github.com/labstack/echo" + + "github.com/stretchr/testify/assert" +) + +func TestChange(t *testing.T) { + type args struct { + oldpass string + newpass string + id int + } + cases := []struct { + name string + args args + wantErr bool + udb *mockdb.User + rbac *mock.RBAC + sec *mock.Secure + }{ + { + name: "Fail on EnforceUser", + args: args{id: 1}, + rbac: &mock.RBAC{ + EnforceUserFn: func(c echo.Context, id int) error { + return gorsk.ErrGeneric + }}, + wantErr: true, + }, + { + name: "Fail on ViewUser", + args: args{id: 1}, + wantErr: true, + rbac: &mock.RBAC{ + EnforceUserFn: func(c echo.Context, id int) error { + return nil + }}, + udb: &mockdb.User{ + ViewFn: func(db orm.DB, id int) (*gorsk.User, error) { + if id != 1 { + return nil, nil + } + return nil, gorsk.ErrGeneric + }, + }, + }, + { + name: "Fail on PasswordMatch", + args: args{id: 1, oldpass: "hunter123"}, + rbac: &mock.RBAC{ + EnforceUserFn: func(c echo.Context, id int) error { + return nil + }}, + wantErr: true, + udb: &mockdb.User{ + ViewFn: func(db orm.DB, id int) (*gorsk.User, error) { + return &gorsk.User{ + Password: "HashedPassword", + }, nil + }, + }, + sec: &mock.Secure{ + HashMatchesPasswordFn: func(string, string) bool { + return false + }, + }, + }, + { + name: "Fail on InsecurePassword", + args: args{id: 1, oldpass: "hunter123"}, + rbac: &mock.RBAC{ + EnforceUserFn: func(c echo.Context, id int) error { + return nil + }}, + wantErr: true, + udb: &mockdb.User{ + ViewFn: func(db orm.DB, id int) (*gorsk.User, error) { + return &gorsk.User{ + Password: "HashedPassword", + }, nil + }, + }, + sec: &mock.Secure{ + HashMatchesPasswordFn: func(string, string) bool { + return true + }, + PasswordFn: func(string, ...string) bool { + return false + }, + }, + }, + { + name: "Success", + args: args{id: 1, oldpass: "hunter123", newpass: "password"}, + rbac: &mock.RBAC{ + EnforceUserFn: func(c echo.Context, id int) error { + return nil + }}, + udb: &mockdb.User{ + ViewFn: func(db orm.DB, id int) (*gorsk.User, error) { + return &gorsk.User{ + Password: "$2a$10$udRBroNGBeOYwSWCVzf6Lulg98uAoRCIi4t75VZg84xgw6EJbFNsG", + }, nil + }, + UpdateFn: func(orm.DB, *gorsk.User) error { + return nil + }, + }, + sec: &mock.Secure{ + HashMatchesPasswordFn: func(string, string) bool { + return true + }, + PasswordFn: func(string, ...string) bool { + return true + }, + HashFn: func(string) string { + return "hash3d" + }, + }, + }, + } + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + s := password.New(nil, tt.udb, tt.rbac, tt.sec) + err := s.Change(nil, tt.args.id, tt.args.oldpass, tt.args.newpass) + assert.Equal(t, tt.wantErr, err != nil) + // Check whether password was changed + }) + } +} + +func TestInitialize(t *testing.T) { + p := password.Initialize(nil, nil, nil) + if p == nil { + t.Error("password service not initialized") + } +} diff --git a/pkg/api/password/platform/pgsql/user.go b/pkg/api/password/platform/pgsql/user.go new file mode 100644 index 0000000..69f7e74 --- /dev/null +++ b/pkg/api/password/platform/pgsql/user.go @@ -0,0 +1,29 @@ +package pgsql + +import ( + "github.com/go-pg/pg/orm" + "github.com/ribice/gorsk/pkg/utl/model" +) + +// NewUser returns a new user database instance +func NewUser() *User { + return &User{} +} + +// User represents the client for user table +type User struct{} + +// View returns single user by ID +func (u *User) View(db orm.DB, id int) (*gorsk.User, error) { + user := &gorsk.User{Base: gorsk.Base{ID: id}} + err := db.Select(user) + if err != nil { + return nil, err + } + return user, nil +} + +// Update updates user's info +func (u *User) Update(db orm.DB, user *gorsk.User) error { + return db.Update(user) +} diff --git a/pkg/api/password/platform/pgsql/user_test.go b/pkg/api/password/platform/pgsql/user_test.go new file mode 100644 index 0000000..8e9d3d9 --- /dev/null +++ b/pkg/api/password/platform/pgsql/user_test.go @@ -0,0 +1,149 @@ +package pgsql_test + +import ( + "testing" + + "github.com/ribice/gorsk/pkg/utl/model" + + "github.com/ribice/gorsk/pkg/api/password/platform/pgsql" + "github.com/ribice/gorsk/pkg/utl/mock" + "github.com/stretchr/testify/assert" +) + +func TestView(t *testing.T) { + cases := []struct { + name string + wantErr bool + id int + wantData *gorsk.User + }{ + { + name: "User does not exist", + wantErr: true, + id: 1000, + }, + { + name: "Success", + id: 2, + wantData: &gorsk.User{ + Email: "tomjones@mail.com", + FirstName: "Tom", + LastName: "Jones", + Username: "tomjones", + RoleID: 1, + CompanyID: 1, + LocationID: 1, + Password: "newPass", + Base: gorsk.Base{ + ID: 2, + }, + }, + }, + } + + dbCon := mock.NewPGContainer(t) + defer dbCon.Shutdown() + + db := mock.NewDB(t, dbCon, &gorsk.Role{}, &gorsk.User{}) + + if err := mock.InsertMultiple(db, &gorsk.Role{ + ID: 1, + AccessLevel: 1, + Name: "SUPER_ADMIN"}, cases[1].wantData); err != nil { + t.Error(err) + } + + udb := pgsql.NewUser() + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + user, err := udb.View(db, tt.id) + assert.Equal(t, tt.wantErr, err != nil) + if tt.wantData != nil { + if user == nil { + t.Errorf("response was nil due to: %v", err) + } else { + tt.wantData.CreatedAt = user.CreatedAt + tt.wantData.UpdatedAt = user.UpdatedAt + assert.Equal(t, tt.wantData, user) + } + } + }) + } +} + +func TestUpdate(t *testing.T) { + cases := []struct { + name string + wantErr bool + usr *gorsk.User + wantData *gorsk.User + }{ + { + name: "Success", + usr: &gorsk.User{ + Base: gorsk.Base{ + ID: 2, + }, + FirstName: "Z", + LastName: "Freak", + Address: "Address", + Phone: "123456", + Mobile: "345678", + Username: "newUsername", + }, + wantData: &gorsk.User{ + Email: "tomjones@mail.com", + FirstName: "Z", + LastName: "Freak", + Username: "tomjones", + RoleID: 1, + CompanyID: 1, + LocationID: 1, + Password: "newPass", + Address: "Address", + Phone: "123456", + Mobile: "345678", + Base: gorsk.Base{ + ID: 2, + }, + }, + }, + } + + dbCon := mock.NewPGContainer(t) + defer dbCon.Shutdown() + + db := mock.NewDB(t, dbCon, &gorsk.Role{}, &gorsk.User{}) + + if err := mock.InsertMultiple(db, &gorsk.Role{ + ID: 1, + AccessLevel: 1, + Name: "SUPER_ADMIN"}, cases[0].usr); err != nil { + t.Error(err) + } + + udb := pgsql.NewUser() + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + err := udb.Update(db, tt.wantData) + assert.Equal(t, tt.wantErr, err != nil) + if tt.wantData != nil { + user := &gorsk.User{ + Base: gorsk.Base{ + ID: tt.usr.ID, + }, + } + if err := db.Select(user); err != nil { + t.Error(err) + } + tt.wantData.UpdatedAt = user.UpdatedAt + tt.wantData.CreatedAt = user.CreatedAt + tt.wantData.LastLogin = user.LastLogin + tt.wantData.DeletedAt = user.DeletedAt + assert.Equal(t, tt.wantData, user) + } + }) + } +} diff --git a/pkg/api/password/service.go b/pkg/api/password/service.go new file mode 100644 index 0000000..b7966dc --- /dev/null +++ b/pkg/api/password/service.go @@ -0,0 +1,55 @@ +package password + +import ( + "github.com/go-pg/pg" + "github.com/go-pg/pg/orm" + "github.com/labstack/echo" + "github.com/ribice/gorsk/pkg/api/password/platform/pgsql" + "github.com/ribice/gorsk/pkg/utl/model" +) + +// Service represents password application interface +type Service interface { + Change(echo.Context, int, string, string) error +} + +// New creates new password application service +func New(db *pg.DB, udb UserDB, rbac RBAC, sec Securer) *Password { + return &Password{ + db: db, + udb: udb, + rbac: rbac, + sec: sec, + } +} + +// Initialize initalizes password application service with defaults +func Initialize(db *pg.DB, rbac RBAC, sec Securer) *Password { + return New(db, pgsql.NewUser(), rbac, sec) +} + +// Password represents password application service +type Password struct { + db *pg.DB + udb UserDB + rbac RBAC + sec Securer +} + +// UserDB represents user repository interface +type UserDB interface { + View(orm.DB, int) (*gorsk.User, error) + Update(orm.DB, *gorsk.User) error +} + +// Securer represents security interface +type Securer interface { + Hash(string) string + HashMatchesPassword(string, string) bool + Password(string, ...string) bool +} + +// RBAC represents role-based-access-control interface +type RBAC interface { + EnforceUser(echo.Context, int) error +} diff --git a/pkg/api/password/transport/http.go b/pkg/api/password/transport/http.go new file mode 100644 index 0000000..a307a67 --- /dev/null +++ b/pkg/api/password/transport/http.go @@ -0,0 +1,88 @@ +package transport + +import ( + "net/http" + "strconv" + + "github.com/ribice/gorsk/pkg/api/password" + + "github.com/ribice/gorsk/pkg/utl/model" + + "github.com/labstack/echo" +) + +// HTTP represents password http transport service +type HTTP struct { + svc password.Service +} + +// NewHTTP creates new password http service +func NewHTTP(svc password.Service, er *echo.Group) { + h := HTTP{svc} + pr := er.Group("/password") + + // swagger:operation PATCH /v1/password/{id} password pwChange + // --- + // summary: Changes user's password. + // description: If user's old passowrd is correct, it will be replaced with new password. + // parameters: + // - name: id + // in: path + // description: id of user + // type: int + // required: true + // - name: request + // in: body + // description: Request body + // required: true + // schema: + // "$ref": "#/definitions/pwChange" + // responses: + // "200": + // "$ref": "#/responses/ok" + // "400": + // "$ref": "#/responses/errMsg" + // "401": + // "$ref": "#/responses/err" + // "403": + // "$ref": "#/responses/err" + // "500": + // "$ref": "#/responses/err" + pr.PATCH("/:id", h.change) +} + +// Custom errors +var ( + ErrPasswordsNotMaching = echo.NewHTTPError(http.StatusBadRequest, "passwords do not match") +) + +// Password change request +// swagger:model pwChange +type changeReq struct { + ID int `json:"-"` + OldPassword string `json:"old_password" validate:"required,min=8"` + NewPassword string `json:"new_password" validate:"required,min=8"` + NewPasswordConfirm string `json:"new_password_confirm" validate:"required"` +} + +func (h *HTTP) change(c echo.Context) error { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + return gorsk.ErrBadRequest + } + + p := new(changeReq) + if err := c.Bind(p); err != nil { + return err + } + + if p.NewPassword != p.NewPasswordConfirm { + return ErrPasswordsNotMaching + } + + if err := h.svc.Change(c, id, p.OldPassword, p.NewPassword); err != nil { + return err + } + + return c.NoContent(http.StatusOK) +} diff --git a/pkg/api/password/transport/http_test.go b/pkg/api/password/transport/http_test.go new file mode 100644 index 0000000..2b3501f --- /dev/null +++ b/pkg/api/password/transport/http_test.go @@ -0,0 +1,117 @@ +package transport_test + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + + "github.com/ribice/gorsk/pkg/api/password" + "github.com/ribice/gorsk/pkg/api/password/transport" + + "github.com/ribice/gorsk/pkg/utl/mock" + "github.com/ribice/gorsk/pkg/utl/mock/mockdb" + "github.com/ribice/gorsk/pkg/utl/model" + "github.com/ribice/gorsk/pkg/utl/server" + + "github.com/go-pg/pg/orm" + "github.com/labstack/echo" + "github.com/stretchr/testify/assert" +) + +func TestChangePassword(t *testing.T) { + cases := []struct { + name string + req string + wantStatus int + id string + udb *mockdb.User + rbac *mock.RBAC + sec *mock.Secure + }{ + { + name: "NaN", + wantStatus: http.StatusBadRequest, + id: "abc", + }, + { + name: "Fail on Bind", + req: `{"new_password":"new","old_password":"my_old_password", "new_password_confirm":"new"}`, + wantStatus: http.StatusBadRequest, + id: "1", + }, + { + name: "Different passwords", + req: `{"new_password":"new_password","old_password":"my_old_password", "new_password_confirm":"new_password_cf"}`, + wantStatus: http.StatusBadRequest, + id: "1", + }, + { + name: "Fail on RBAC", + req: `{"new_password":"newpassw","old_password":"oldpassw", "new_password_confirm":"newpassw"}`, + rbac: &mock.RBAC{ + EnforceUserFn: func(c echo.Context, id int) error { + return echo.ErrForbidden + }, + }, + id: "1", + wantStatus: http.StatusForbidden, + }, + { + name: "Success", + req: `{"new_password":"newpassw","old_password":"oldpassw", "new_password_confirm":"newpassw"}`, + rbac: &mock.RBAC{ + EnforceUserFn: func(c echo.Context, id int) error { + return nil + }, + }, + id: "1", + udb: &mockdb.User{ + ViewFn: func(db orm.DB, id int) (*gorsk.User, error) { + return &gorsk.User{ + Password: "oldPassword", + }, nil + }, + UpdateFn: func(db orm.DB, usr *gorsk.User) error { + return nil + }, + }, + sec: &mock.Secure{ + HashMatchesPasswordFn: func(string, string) bool { + return true + }, + PasswordFn: func(string, ...string) bool { + return true + }, + HashFn: func(string) string { + return "hashedPassword" + }, + }, + wantStatus: http.StatusOK, + }, + } + + client := &http.Client{} + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + r := server.New() + rg := r.Group("") + transport.NewHTTP(password.New(nil, tt.udb, tt.rbac, tt.sec), rg) + ts := httptest.NewServer(r) + defer ts.Close() + path := ts.URL + "/password/" + tt.id + req, err := http.NewRequest("PATCH", path, bytes.NewBufferString(tt.req)) + req.Header.Set("Content-Type", "application/json") + if err != nil { + t.Fatal(err) + } + res, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + assert.Equal(t, tt.wantStatus, res.StatusCode) + }) + } +} diff --git a/pkg/api/user/platform/pgsql/user.go b/pkg/api/user/platform/pgsql/user.go new file mode 100644 index 0000000..adf311f --- /dev/null +++ b/pkg/api/user/platform/pgsql/user.go @@ -0,0 +1,79 @@ +package pgsql + +import ( + "net/http" + "strings" + + "github.com/go-pg/pg" + + "github.com/go-pg/pg/orm" + "github.com/labstack/echo" + "github.com/ribice/gorsk/pkg/utl/model" +) + +// NewUser returns a new user database instance +func NewUser() *User { + return &User{} +} + +// User represents the client for user table +type User struct{} + +// Custom errors +var ( + ErrAlreadyExists = echo.NewHTTPError(http.StatusInternalServerError, "Username or email already exists.") +) + +// Create creates a new user on database +func (u *User) Create(db orm.DB, usr gorsk.User) (*gorsk.User, error) { + var user = new(gorsk.User) + err := db.Model(user).Where("lower(username) = ? or lower(email) = ? and deleted_at is null", + strings.ToLower(usr.Username), strings.ToLower(usr.Email)).Select() + + if err != nil && err != pg.ErrNoRows { + return nil, ErrAlreadyExists + + } + + if err := db.Insert(&usr); err != nil { + return nil, err + } + return &usr, nil +} + +// View returns single user by ID +func (u *User) View(db orm.DB, id int) (*gorsk.User, error) { + var user = new(gorsk.User) + sql := `SELECT "user".*, "role"."id" AS "role__id", "role"."access_level" AS "role__access_level", "role"."name" AS "role__name" + FROM "users" AS "user" LEFT JOIN "roles" AS "role" ON "role"."id" = "user"."role_id" + WHERE ("user"."id" = ? and deleted_at is null)` + _, err := db.QueryOne(user, sql, id) + if err != nil { + return nil, err + } + + return user, nil +} + +// Update updates user's contact info +func (u *User) Update(db orm.DB, user *gorsk.User) error { + return db.Update(user) +} + +// List returns list of all users retrievable for the current user, depending on role +func (u *User) List(db orm.DB, qp *gorsk.ListQuery, p *gorsk.Pagination) ([]gorsk.User, error) { + var users []gorsk.User + q := db.Model(&users).Column("user.*", "Role").Limit(p.Limit).Offset(p.Offset).Where("deleted_at is null").Order("user.id desc") + if qp != nil { + q.Where(qp.Query, qp.ID) + } + if err := q.Select(); err != nil { + return nil, err + } + return users, nil +} + +// Delete sets deleted_at for a user +func (u *User) Delete(db orm.DB, user *gorsk.User) error { + return db.Delete(user) +} diff --git a/pkg/api/user/platform/pgsql/user_test.go b/pkg/api/user/platform/pgsql/user_test.go new file mode 100644 index 0000000..d6831ab --- /dev/null +++ b/pkg/api/user/platform/pgsql/user_test.go @@ -0,0 +1,400 @@ +package pgsql_test + +import ( + "testing" + + "github.com/ribice/gorsk/pkg/utl/model" + + "github.com/ribice/gorsk/pkg/api/user/platform/pgsql" + "github.com/ribice/gorsk/pkg/utl/mock" + "github.com/stretchr/testify/assert" +) + +func TestCreate(t *testing.T) { + cases := []struct { + name string + wantErr bool + req gorsk.User + wantData *gorsk.User + }{ + { + name: "User already exists", + wantErr: true, + req: gorsk.User{ + Email: "johndoe@mail.com", + Username: "johndoe", + }, + }, + { + name: "Fail on insert duplicate ID", + wantErr: true, + req: gorsk.User{ + Email: "tomjones@mail.com", + FirstName: "Tom", + LastName: "Jones", + Username: "tomjones", + RoleID: 1, + CompanyID: 1, + LocationID: 1, + Password: "pass", + Base: gorsk.Base{ + ID: 1, + }, + }, + }, + { + name: "Success", + req: gorsk.User{ + Email: "newtomjones@mail.com", + FirstName: "Tom", + LastName: "Jones", + Username: "newtomjones", + RoleID: 1, + CompanyID: 1, + LocationID: 1, + Password: "pass", + Base: gorsk.Base{ + ID: 2, + }, + }, + wantData: &gorsk.User{ + Email: "newtomjones@mail.com", + FirstName: "Tom", + LastName: "Jones", + Username: "newtomjones", + RoleID: 1, + CompanyID: 1, + LocationID: 1, + Password: "pass", + Base: gorsk.Base{ + ID: 2, + }, + }, + }, + } + + dbCon := mock.NewPGContainer(t) + defer dbCon.Shutdown() + + db := mock.NewDB(t, dbCon, &gorsk.Role{}, &gorsk.User{}) + + if err := mock.InsertMultiple(db, &gorsk.Role{ + ID: 1, + AccessLevel: 1, + Name: "SUPER_ADMIN"}, &cases[1].req); err != nil { + t.Error(err) + } + + udb := pgsql.NewUser() + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + resp, err := udb.Create(db, tt.req) + assert.Equal(t, tt.wantErr, err != nil) + if tt.wantData != nil { + if resp == nil { + t.Error("Expected data, but received nil.") + return + } + tt.wantData.CreatedAt = resp.CreatedAt + tt.wantData.UpdatedAt = resp.UpdatedAt + assert.Equal(t, tt.wantData, resp) + } + }) + } +} + +func TestView(t *testing.T) { + cases := []struct { + name string + wantErr bool + id int + wantData *gorsk.User + }{ + { + name: "User does not exist", + wantErr: true, + id: 1000, + }, + { + name: "Success", + id: 2, + wantData: &gorsk.User{ + Email: "tomjones@mail.com", + FirstName: "Tom", + LastName: "Jones", + Username: "tomjones", + RoleID: 1, + CompanyID: 1, + LocationID: 1, + Password: "newPass", + Base: gorsk.Base{ + ID: 2, + }, + Role: &gorsk.Role{ + ID: 1, + AccessLevel: 1, + Name: "SUPER_ADMIN", + }, + }, + }, + } + + dbCon := mock.NewPGContainer(t) + defer dbCon.Shutdown() + + db := mock.NewDB(t, dbCon, &gorsk.Role{}, &gorsk.User{}) + + if err := mock.InsertMultiple(db, &gorsk.Role{ + ID: 1, + AccessLevel: 1, + Name: "SUPER_ADMIN"}, cases[1].wantData); err != nil { + t.Error(err) + } + + udb := pgsql.NewUser() + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + user, err := udb.View(db, tt.id) + assert.Equal(t, tt.wantErr, err != nil) + if tt.wantData != nil { + if user == nil { + t.Errorf("response was nil due to: %v", err) + } else { + tt.wantData.CreatedAt = user.CreatedAt + tt.wantData.UpdatedAt = user.UpdatedAt + assert.Equal(t, tt.wantData, user) + } + } + }) + } +} + +func TestUpdate(t *testing.T) { + cases := []struct { + name string + wantErr bool + usr *gorsk.User + wantData *gorsk.User + }{ + { + name: "Success", + usr: &gorsk.User{ + Base: gorsk.Base{ + ID: 2, + }, + FirstName: "Z", + LastName: "Freak", + Address: "Address", + Phone: "123456", + Mobile: "345678", + Username: "newUsername", + }, + wantData: &gorsk.User{ + Email: "tomjones@mail.com", + FirstName: "Z", + LastName: "Freak", + Username: "tomjones", + RoleID: 1, + CompanyID: 1, + LocationID: 1, + Password: "newPass", + Address: "Address", + Phone: "123456", + Mobile: "345678", + Base: gorsk.Base{ + ID: 2, + }, + }, + }, + } + + dbCon := mock.NewPGContainer(t) + defer dbCon.Shutdown() + + db := mock.NewDB(t, dbCon, &gorsk.Role{}, &gorsk.User{}) + + if err := mock.InsertMultiple(db, &gorsk.Role{ + ID: 1, + AccessLevel: 1, + Name: "SUPER_ADMIN"}, cases[0].usr); err != nil { + t.Error(err) + } + + udb := pgsql.NewUser() + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + err := udb.Update(db, tt.wantData) + assert.Equal(t, tt.wantErr, err != nil) + if tt.wantData != nil { + user := &gorsk.User{ + Base: gorsk.Base{ + ID: tt.usr.ID, + }, + } + if err := db.Select(user); err != nil { + t.Error(err) + } + tt.wantData.UpdatedAt = user.UpdatedAt + tt.wantData.CreatedAt = user.CreatedAt + tt.wantData.LastLogin = user.LastLogin + tt.wantData.DeletedAt = user.DeletedAt + assert.Equal(t, tt.wantData, user) + } + }) + } +} + +func TestList(t *testing.T) { + cases := []struct { + name string + wantErr bool + qp *gorsk.ListQuery + pg *gorsk.Pagination + wantData []gorsk.User + }{ + { + name: "Invalid pagination values", + wantErr: true, + pg: &gorsk.Pagination{ + Limit: -100, + }, + }, + { + name: "Success", + pg: &gorsk.Pagination{ + Limit: 100, + Offset: 0, + }, + qp: &gorsk.ListQuery{ + ID: 1, + Query: "company_id = ?", + }, + wantData: []gorsk.User{ + { + Email: "tomjones@mail.com", + FirstName: "Tom", + LastName: "Jones", + Username: "tomjones", + RoleID: 1, + CompanyID: 1, + LocationID: 1, + Password: "newPass", + Base: gorsk.Base{ + ID: 2, + }, + Role: &gorsk.Role{ + ID: 1, + AccessLevel: 1, + Name: "SUPER_ADMIN", + }, + }, + { + Email: "johndoe@mail.com", + FirstName: "John", + LastName: "Doe", + Username: "johndoe", + RoleID: 1, + CompanyID: 1, + LocationID: 1, + Password: "hunter2", + Base: gorsk.Base{ + ID: 1, + }, + Role: &gorsk.Role{ + ID: 1, + AccessLevel: 1, + Name: "SUPER_ADMIN", + }, + Token: "loginrefresh", + }, + }, + }, + } + + dbCon := mock.NewPGContainer(t) + defer dbCon.Shutdown() + + db := mock.NewDB(t, dbCon, &gorsk.Role{}, &gorsk.User{}) + + if err := mock.InsertMultiple(db, &gorsk.Role{ + ID: 1, + AccessLevel: 1, + Name: "SUPER_ADMIN"}, &cases[1].wantData); err != nil { + t.Error(err) + } + + udb := pgsql.NewUser() + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + users, err := udb.List(db, tt.qp, tt.pg) + assert.Equal(t, tt.wantErr, err != nil) + if tt.wantData != nil { + for i, v := range users { + tt.wantData[i].CreatedAt = v.CreatedAt + tt.wantData[i].UpdatedAt = v.UpdatedAt + } + assert.Equal(t, tt.wantData, users) + } + }) + } +} + +func TestDelete(t *testing.T) { + cases := []struct { + name string + wantErr bool + usr *gorsk.User + wantData *gorsk.User + }{ + { + name: "Success", + usr: &gorsk.User{ + Base: gorsk.Base{ + ID: 2, + DeletedAt: mock.TestTime(2018), + }, + }, + wantData: &gorsk.User{ + Email: "tomjones@mail.com", + FirstName: "Tom", + LastName: "Jones", + Username: "tomjones", + RoleID: 1, + CompanyID: 1, + LocationID: 1, + Password: "newPass", + Base: gorsk.Base{ + ID: 2, + }, + }, + }, + } + + dbCon := mock.NewPGContainer(t) + defer dbCon.Shutdown() + + db := mock.NewDB(t, dbCon, &gorsk.Role{}, &gorsk.User{}) + + if err := mock.InsertMultiple(db, &gorsk.Role{ + ID: 1, + AccessLevel: 1, + Name: "SUPER_ADMIN"}, cases[0].wantData); err != nil { + t.Error(err) + } + + udb := pgsql.NewUser() + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + + err := udb.Delete(db, tt.usr) + assert.Equal(t, tt.wantErr, err != nil) + + // Check if the deleted_at was set + }) + } +} diff --git a/pkg/api/user/service.go b/pkg/api/user/service.go new file mode 100644 index 0000000..2ba5219 --- /dev/null +++ b/pkg/api/user/service.go @@ -0,0 +1,58 @@ +package user + +import ( + "github.com/go-pg/pg" + "github.com/go-pg/pg/orm" + "github.com/labstack/echo" + "github.com/ribice/gorsk/pkg/api/user/platform/pgsql" + "github.com/ribice/gorsk/pkg/utl/model" +) + +// Service represents user application interface +type Service interface { + Create(echo.Context, gorsk.User) (*gorsk.User, error) + List(echo.Context, *gorsk.Pagination) ([]gorsk.User, error) + View(echo.Context, int) (*gorsk.User, error) + Delete(echo.Context, int) error + Update(echo.Context, *Update) (*gorsk.User, error) +} + +// New creates new user application service +func New(db *pg.DB, udb UDB, rbac RBAC, sec Securer) *User { + return &User{db: db, udb: udb, rbac: rbac, sec: sec} +} + +// Initialize initalizes User application service with defaults +func Initialize(db *pg.DB, rbac RBAC, sec Securer) *User { + return New(db, pgsql.NewUser(), rbac, sec) +} + +// User represents user application service +type User struct { + db *pg.DB + udb UDB + rbac RBAC + sec Securer +} + +// Securer represents security interface +type Securer interface { + Hash(string) string +} + +// UDB represents user repository interface +type UDB interface { + Create(orm.DB, gorsk.User) (*gorsk.User, error) + View(orm.DB, int) (*gorsk.User, error) + List(orm.DB, *gorsk.ListQuery, *gorsk.Pagination) ([]gorsk.User, error) + Update(orm.DB, *gorsk.User) error + Delete(orm.DB, *gorsk.User) error +} + +// RBAC represents role-based-access-control interface +type RBAC interface { + User(echo.Context) *gorsk.AuthUser + EnforceUser(echo.Context, int) error + AccountCreate(echo.Context, gorsk.AccessRole, int, int) error + IsLowerRole(echo.Context, gorsk.AccessRole) error +} diff --git a/cmd/api/service/user.go b/pkg/api/user/transport/http.go similarity index 51% rename from cmd/api/service/user.go rename to pkg/api/user/transport/http.go index 977ff38..386521a 100644 --- a/cmd/api/service/user.go +++ b/pkg/api/user/transport/http.go @@ -1,25 +1,35 @@ -package service +package transport import ( "net/http" + "strconv" - "github.com/labstack/echo" - - "github.com/ribice/gorsk/internal" + "github.com/ribice/gorsk/pkg/api/user" - "github.com/ribice/gorsk/internal/user" + "github.com/ribice/gorsk/pkg/utl/model" - "github.com/ribice/gorsk/cmd/api/request" + "github.com/labstack/echo" ) -// User represents user http service -type User struct { - svc *user.Service +// HTTP represents user http service +type HTTP struct { + svc user.Service } -// NewUser creates new user http service -func NewUser(svc *user.Service, ur *echo.Group) { - u := User{svc: svc} +// NewHTTP creates new user http service +func NewHTTP(svc user.Service, er *echo.Group) { + h := HTTP{svc} + ur := er.Group("/users") + // swagger:route POST /v1/users users userCreate + // Creates new user account. + // responses: + // 200: userResp + // 400: errMsg + // 401: err + // 403: errMsg + // 500: err + ur.POST("", h.create) + // swagger:operation GET /v1/users users listUsers // --- // summary: Returns list of users. @@ -46,7 +56,8 @@ func NewUser(svc *user.Service, ur *echo.Group) { // "$ref": "#/responses/err" // "500": // "$ref": "#/responses/err" - ur.GET("", u.list) + ur.GET("", h.list) + // swagger:operation GET /v1/users/{id} users getUser // --- // summary: Returns a single user. @@ -70,7 +81,8 @@ func NewUser(svc *user.Service, ur *echo.Group) { // "$ref": "#/responses/err" // "500": // "$ref": "#/responses/err" - ur.GET("/:id", u.view) + ur.GET("/:id", h.view) + // swagger:operation PATCH /v1/users/{id} users userUpdate // --- // summary: Updates user's contact information @@ -98,7 +110,8 @@ func NewUser(svc *user.Service, ur *echo.Group) { // "$ref": "#/responses/err" // "500": // "$ref": "#/responses/err" - ur.PATCH("/:id", u.update) + ur.PATCH("/:id", h.update) + // swagger:operation DELETE /v1/users/{id} users userDelete // --- // summary: Deletes a user @@ -120,66 +133,144 @@ func NewUser(svc *user.Service, ur *echo.Group) { // "$ref": "#/responses/err" // "500": // "$ref": "#/responses/err" - ur.DELETE("/:id", u.delete) + ur.DELETE("/:id", h.delete) +} + +// Custom errors +var ( + ErrPasswordsNotMaching = echo.NewHTTPError(http.StatusBadRequest, "passwords do not match") +) + +// User create request +// swagger:model userCreate +type createReq struct { + FirstName string `json:"first_name" validate:"required"` + LastName string `json:"last_name" validate:"required"` + Username string `json:"username" validate:"required,min=3,alphanum"` + Password string `json:"password" validate:"required,min=8"` + PasswordConfirm string `json:"password_confirm" validate:"required"` + Email string `json:"email" validate:"required,email"` + + CompanyID int `json:"company_id" validate:"required"` + LocationID int `json:"location_id" validate:"required"` + RoleID gorsk.AccessRole `json:"role_id" validate:"required"` +} + +func (h *HTTP) create(c echo.Context) error { + r := new(createReq) + + if err := c.Bind(r); err != nil { + + return err + } + + if r.Password != r.PasswordConfirm { + return ErrPasswordsNotMaching + } + + if r.RoleID < gorsk.SuperAdminRole || r.RoleID > gorsk.UserRole { + return gorsk.ErrBadRequest + } + + usr, err := h.svc.Create(c, gorsk.User{ + Username: r.Username, + Password: r.Password, + Email: r.Email, + FirstName: r.FirstName, + LastName: r.LastName, + CompanyID: r.CompanyID, + LocationID: r.LocationID, + RoleID: r.RoleID, + }) + + if err != nil { + return err + } + + return c.JSON(http.StatusOK, usr) } type listResponse struct { - Users []model.User `json:"users"` + Users []gorsk.User `json:"users"` Page int `json:"page"` } -func (u *User) list(c echo.Context) error { - p, err := request.Paginate(c) - if err != nil { +func (h *HTTP) list(c echo.Context) error { + p := new(gorsk.PaginationReq) + if err := c.Bind(p); err != nil { return err } - result, err := u.svc.List(c, &model.Pagination{ - Limit: p.Limit, Offset: p.Offset, - }) + + result, err := h.svc.List(c, p.Transform()) + if err != nil { return err } + return c.JSON(http.StatusOK, listResponse{result, p.Page}) } -func (u *User) view(c echo.Context) error { - id, err := request.ID(c) +func (h *HTTP) view(c echo.Context) error { + id, err := strconv.Atoi(c.Param("id")) if err != nil { - return err + return gorsk.ErrBadRequest } - result, err := u.svc.View(c, id) + + result, err := h.svc.View(c, id) if err != nil { return err } + return c.JSON(http.StatusOK, result) } -func (u *User) update(c echo.Context) error { - req, err := request.UserUpdate(c) +// User update request +// swagger:model userUpdate +type updateReq struct { + ID int `json:"-"` + FirstName *string `json:"first_name,omitempty" validate:"omitempty,min=2"` + LastName *string `json:"last_name,omitempty" validate:"omitempty,min=2"` + Mobile *string `json:"mobile,omitempty"` + Phone *string `json:"phone,omitempty"` + Address *string `json:"address,omitempty"` +} + +func (h *HTTP) update(c echo.Context) error { + id, err := strconv.Atoi(c.Param("id")) if err != nil { + return gorsk.ErrBadRequest + } + + req := new(updateReq) + if err := c.Bind(req); err != nil { return err } - usr, err := u.svc.Update(c, &user.Update{ - ID: req.ID, + + usr, err := h.svc.Update(c, &user.Update{ + ID: id, FirstName: req.FirstName, LastName: req.LastName, Mobile: req.Mobile, Phone: req.Phone, Address: req.Address, }) + if err != nil { return err } + return c.JSON(http.StatusOK, usr) } -func (u *User) delete(c echo.Context) error { - id, err := request.ID(c) +func (h *HTTP) delete(c echo.Context) error { + id, err := strconv.Atoi(c.Param("id")) if err != nil { - return err + return gorsk.ErrBadRequest } - if err := u.svc.Delete(c, id); err != nil { + + if err := h.svc.Delete(c, id); err != nil { return err } + return c.NoContent(http.StatusOK) } diff --git a/cmd/api/service/user_test.go b/pkg/api/user/transport/http_test.go similarity index 54% rename from cmd/api/service/user_test.go rename to pkg/api/user/transport/http_test.go index 77d24e1..b7f58ca 100644 --- a/cmd/api/service/user_test.go +++ b/pkg/api/user/transport/http_test.go @@ -1,4 +1,4 @@ -package service_test +package transport_test import ( "bytes" @@ -7,23 +7,127 @@ import ( "net/http/httptest" "testing" + "github.com/ribice/gorsk/pkg/utl/model" + + "github.com/ribice/gorsk/pkg/api/user" + "github.com/ribice/gorsk/pkg/api/user/transport" + + "github.com/ribice/gorsk/pkg/utl/mock" + "github.com/ribice/gorsk/pkg/utl/mock/mockdb" + "github.com/ribice/gorsk/pkg/utl/server" + "github.com/go-pg/pg/orm" "github.com/labstack/echo" "github.com/stretchr/testify/assert" +) - "github.com/ribice/gorsk/internal/user" +func TestCreate(t *testing.T) { + cases := []struct { + name string + req string + wantStatus int + wantResp *gorsk.User + udb *mockdb.User + rbac *mock.RBAC + sec *mock.Secure + }{ + { + name: "Fail on validation", + req: `{"first_name":"John","last_name":"Doe","username":"ju","password":"hunter123","password_confirm":"hunter123","email":"johndoe@gmail.com","company_id":1,"location_id":2,"role_id":300}`, + wantStatus: http.StatusBadRequest, + }, + { + name: "Fail on non-matching passwords", + req: `{"first_name":"John","last_name":"Doe","username":"juzernejm","password":"hunter123","password_confirm":"hunter1234","email":"johndoe@gmail.com","company_id":1,"location_id":2,"role_id":300}`, + wantStatus: http.StatusBadRequest, + }, + { + name: "Fail on invalid role", + req: `{"first_name":"John","last_name":"Doe","username":"juzernejm","password":"hunter123","password_confirm":"hunter123","email":"johndoe@gmail.com","company_id":1,"location_id":2,"role_id":50}`, + rbac: &mock.RBAC{ + AccountCreateFn: func(c echo.Context, roleID gorsk.AccessRole, companyID, locationID int) error { + return echo.ErrForbidden + }, + }, + wantStatus: http.StatusBadRequest, + }, + { + name: "Fail on RBAC", + req: `{"first_name":"John","last_name":"Doe","username":"juzernejm","password":"hunter123","password_confirm":"hunter123","email":"johndoe@gmail.com","company_id":1,"location_id":2,"role_id":200}`, + rbac: &mock.RBAC{ + AccountCreateFn: func(c echo.Context, roleID gorsk.AccessRole, companyID, locationID int) error { + return echo.ErrForbidden + }, + }, + wantStatus: http.StatusForbidden, + }, - "github.com/ribice/gorsk/internal" + { + name: "Success", + req: `{"first_name":"John","last_name":"Doe","username":"juzernejm","password":"hunter123","password_confirm":"hunter123","email":"johndoe@gmail.com","company_id":1,"location_id":2,"role_id":200}`, + rbac: &mock.RBAC{ + AccountCreateFn: func(c echo.Context, roleID gorsk.AccessRole, companyID, locationID int) error { + return nil + }, + }, + udb: &mockdb.User{ + CreateFn: func(db orm.DB, usr gorsk.User) (*gorsk.User, error) { + usr.ID = 1 + usr.CreatedAt = mock.TestTime(2018) + usr.UpdatedAt = mock.TestTime(2018) + return &usr, nil + }, + }, + sec: &mock.Secure{ + HashFn: func(string) string { + return "h4$h3d" + }, + }, + wantResp: &gorsk.User{ + Base: gorsk.Base{ + ID: 1, + CreatedAt: mock.TestTime(2018), + UpdatedAt: mock.TestTime(2018), + }, + FirstName: "John", + LastName: "Doe", + Username: "juzernejm", + Email: "johndoe@gmail.com", + CompanyID: 1, + LocationID: 2, + }, + wantStatus: http.StatusOK, + }, + } - "github.com/ribice/gorsk/cmd/api/server" - "github.com/ribice/gorsk/cmd/api/service" - "github.com/ribice/gorsk/internal/mock" - "github.com/ribice/gorsk/internal/mock/mockdb" -) + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + r := server.New() + rg := r.Group("") + transport.NewHTTP(user.New(nil, tt.udb, tt.rbac, tt.sec), rg) + ts := httptest.NewServer(r) + defer ts.Close() + path := ts.URL + "/users" + res, err := http.Post(path, "application/json", bytes.NewBufferString(tt.req)) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + if tt.wantResp != nil { + response := new(gorsk.User) + if err := json.NewDecoder(res.Body).Decode(response); err != nil { + t.Fatal(err) + } + assert.Equal(t, tt.wantResp, response) + } + assert.Equal(t, tt.wantStatus, res.StatusCode) + }) + } +} -func TestListUsers(t *testing.T) { +func TestList(t *testing.T) { type listResponse struct { - Users []model.User `json:"users"` + Users []gorsk.User `json:"users"` Page int `json:"page"` } cases := []struct { @@ -33,7 +137,7 @@ func TestListUsers(t *testing.T) { wantResp *listResponse udb *mockdb.User rbac *mock.RBAC - auth *mock.Auth + sec *mock.Secure }{ { name: "Invalid request", @@ -43,13 +147,13 @@ func TestListUsers(t *testing.T) { { name: "Fail on query list", req: `?limit=100&page=1`, - auth: &mock.Auth{ - UserFn: func(c echo.Context) *model.AuthUser { - return &model.AuthUser{ + rbac: &mock.RBAC{ + UserFn: func(c echo.Context) *gorsk.AuthUser { + return &gorsk.AuthUser{ ID: 1, CompanyID: 2, LocationID: 3, - Role: model.UserRole, + Role: gorsk.UserRole, Email: "john@mail.com", } }}, @@ -58,22 +162,22 @@ func TestListUsers(t *testing.T) { { name: "Success", req: `?limit=100&page=1`, - auth: &mock.Auth{ - UserFn: func(c echo.Context) *model.AuthUser { - return &model.AuthUser{ + rbac: &mock.RBAC{ + UserFn: func(c echo.Context) *gorsk.AuthUser { + return &gorsk.AuthUser{ ID: 1, CompanyID: 2, LocationID: 3, - Role: model.SuperAdminRole, + Role: gorsk.SuperAdminRole, Email: "john@mail.com", } }}, udb: &mockdb.User{ - ListFn: func(db orm.DB, q *model.ListQuery, p *model.Pagination) ([]model.User, error) { + ListFn: func(db orm.DB, q *gorsk.ListQuery, p *gorsk.Pagination) ([]gorsk.User, error) { if p.Limit == 100 && p.Offset == 100 { - return []model.User{ + return []gorsk.User{ { - Base: model.Base{ + Base: gorsk.Base{ ID: 10, CreatedAt: mock.TestTime(2001), UpdatedAt: mock.TestTime(2002), @@ -83,14 +187,14 @@ func TestListUsers(t *testing.T) { Email: "john@mail.com", CompanyID: 2, LocationID: 3, - Role: &model.Role{ + Role: &gorsk.Role{ ID: 1, AccessLevel: 1, Name: "SUPER_ADMIN", }, }, { - Base: model.Base{ + Base: gorsk.Base{ ID: 11, CreatedAt: mock.TestTime(2004), UpdatedAt: mock.TestTime(2005), @@ -100,7 +204,7 @@ func TestListUsers(t *testing.T) { Email: "joanna@mail.com", CompanyID: 1, LocationID: 2, - Role: &model.Role{ + Role: &gorsk.Role{ ID: 2, AccessLevel: 2, Name: "ADMIN", @@ -108,14 +212,14 @@ func TestListUsers(t *testing.T) { }, }, nil } - return nil, model.ErrGeneric + return nil, gorsk.ErrGeneric }, }, wantStatus: http.StatusOK, wantResp: &listResponse{ - Users: []model.User{ + Users: []gorsk.User{ { - Base: model.Base{ + Base: gorsk.Base{ ID: 10, CreatedAt: mock.TestTime(2001), UpdatedAt: mock.TestTime(2002), @@ -125,14 +229,14 @@ func TestListUsers(t *testing.T) { Email: "john@mail.com", CompanyID: 2, LocationID: 3, - Role: &model.Role{ + Role: &gorsk.Role{ ID: 1, AccessLevel: 1, Name: "SUPER_ADMIN", }, }, { - Base: model.Base{ + Base: gorsk.Base{ ID: 11, CreatedAt: mock.TestTime(2004), UpdatedAt: mock.TestTime(2005), @@ -142,7 +246,7 @@ func TestListUsers(t *testing.T) { Email: "joanna@mail.com", CompanyID: 1, LocationID: 2, - Role: &model.Role{ + Role: &gorsk.Role{ ID: 2, AccessLevel: 2, Name: "ADMIN", @@ -155,11 +259,11 @@ func TestListUsers(t *testing.T) { for _, tt := range cases { t.Run(tt.name, func(t *testing.T) { r := server.New() - rg := r.Group("/v1/users") - service.NewUser(user.New(nil, tt.udb, tt.rbac, tt.auth), rg) + rg := r.Group("") + transport.NewHTTP(user.New(nil, tt.udb, tt.rbac, tt.sec), rg) ts := httptest.NewServer(r) defer ts.Close() - path := ts.URL + "/v1/users" + tt.req + path := ts.URL + "/users" + tt.req res, err := http.Get(path) if err != nil { t.Fatal(err) @@ -177,15 +281,15 @@ func TestListUsers(t *testing.T) { } } -func TestViewUser(t *testing.T) { +func TestView(t *testing.T) { cases := []struct { name string req string wantStatus int - wantResp *model.User + wantResp *gorsk.User udb *mockdb.User rbac *mock.RBAC - auth *mock.Auth + sec *mock.Secure }{ { name: "Invalid request", @@ -211,9 +315,9 @@ func TestViewUser(t *testing.T) { }, }, udb: &mockdb.User{ - ViewFn: func(db orm.DB, id int) (*model.User, error) { - return &model.User{ - Base: model.Base{ + ViewFn: func(db orm.DB, id int) (*gorsk.User, error) { + return &gorsk.User{ + Base: gorsk.Base{ ID: 1, CreatedAt: mock.TestTime(2000), UpdatedAt: mock.TestTime(2000), @@ -225,8 +329,8 @@ func TestViewUser(t *testing.T) { }, }, wantStatus: http.StatusOK, - wantResp: &model.User{ - Base: model.Base{ + wantResp: &gorsk.User{ + Base: gorsk.Base{ ID: 1, CreatedAt: mock.TestTime(2000), UpdatedAt: mock.TestTime(2000), @@ -241,18 +345,18 @@ func TestViewUser(t *testing.T) { for _, tt := range cases { t.Run(tt.name, func(t *testing.T) { r := server.New() - rg := r.Group("/v1/users") - service.NewUser(user.New(nil, tt.udb, tt.rbac, tt.auth), rg) + rg := r.Group("") + transport.NewHTTP(user.New(nil, tt.udb, tt.rbac, tt.sec), rg) ts := httptest.NewServer(r) defer ts.Close() - path := ts.URL + "/v1/users/" + tt.req + path := ts.URL + "/users/" + tt.req res, err := http.Get(path) if err != nil { t.Fatal(err) } defer res.Body.Close() if tt.wantResp != nil { - response := new(model.User) + response := new(gorsk.User) if err := json.NewDecoder(res.Body).Decode(response); err != nil { t.Fatal(err) } @@ -263,22 +367,28 @@ func TestViewUser(t *testing.T) { } } -func TestUpdateUser(t *testing.T) { +func TestUpdate(t *testing.T) { cases := []struct { name string req string id string wantStatus int - wantResp *model.User + wantResp *gorsk.User udb *mockdb.User rbac *mock.RBAC - auth *mock.Auth + sec *mock.Secure }{ { name: "Invalid request", id: `a`, wantStatus: http.StatusBadRequest, }, + { + name: "Fail on validation", + id: `1`, + req: `{"first_name":"j","last_name":"okocha","mobile":"123456","phone":"321321","address":"home"}`, + wantStatus: http.StatusBadRequest, + }, { name: "Fail on RBAC", id: `1`, @@ -300,9 +410,9 @@ func TestUpdateUser(t *testing.T) { }, }, udb: &mockdb.User{ - ViewFn: func(db orm.DB, id int) (*model.User, error) { - return &model.User{ - Base: model.Base{ + ViewFn: func(db orm.DB, id int) (*gorsk.User, error) { + return &gorsk.User{ + Base: gorsk.Base{ ID: 1, CreatedAt: mock.TestTime(2000), UpdatedAt: mock.TestTime(2000), @@ -314,15 +424,15 @@ func TestUpdateUser(t *testing.T) { Phone: "332223", }, nil }, - UpdateFn: func(db orm.DB, usr *model.User) (*model.User, error) { + UpdateFn: func(db orm.DB, usr *gorsk.User) error { usr.UpdatedAt = mock.TestTime(2010) usr.Mobile = "991991" - return usr, nil + return nil }, }, wantStatus: http.StatusOK, - wantResp: &model.User{ - Base: model.Base{ + wantResp: &gorsk.User{ + Base: gorsk.Base{ ID: 1, CreatedAt: mock.TestTime(2000), UpdatedAt: mock.TestTime(2010), @@ -342,11 +452,11 @@ func TestUpdateUser(t *testing.T) { for _, tt := range cases { t.Run(tt.name, func(t *testing.T) { r := server.New() - rg := r.Group("/v1/users") - service.NewUser(user.New(nil, tt.udb, tt.rbac, tt.auth), rg) + rg := r.Group("") + transport.NewHTTP(user.New(nil, tt.udb, tt.rbac, tt.sec), rg) ts := httptest.NewServer(r) defer ts.Close() - path := ts.URL + "/v1/users/" + tt.id + path := ts.URL + "/users/" + tt.id req, _ := http.NewRequest("PATCH", path, bytes.NewBufferString(tt.req)) req.Header.Set("Content-Type", "application/json") res, err := client.Do(req) @@ -355,7 +465,7 @@ func TestUpdateUser(t *testing.T) { } defer res.Body.Close() if tt.wantResp != nil { - response := new(model.User) + response := new(gorsk.User) if err := json.NewDecoder(res.Body).Decode(response); err != nil { t.Fatal(err) } @@ -366,14 +476,14 @@ func TestUpdateUser(t *testing.T) { } } -func TestDeleteUser(t *testing.T) { +func TestDelete(t *testing.T) { cases := []struct { name string id string wantStatus int udb *mockdb.User rbac *mock.RBAC - auth *mock.Auth + sec *mock.Secure }{ { name: "Invalid request", @@ -384,16 +494,16 @@ func TestDeleteUser(t *testing.T) { name: "Fail on RBAC", id: `1`, udb: &mockdb.User{ - ViewFn: func(db orm.DB, id int) (*model.User, error) { - return &model.User{ - Role: &model.Role{ - AccessLevel: model.CompanyAdminRole, + ViewFn: func(db orm.DB, id int) (*gorsk.User, error) { + return &gorsk.User{ + Role: &gorsk.Role{ + AccessLevel: gorsk.CompanyAdminRole, }, }, nil }, }, rbac: &mock.RBAC{ - IsLowerRoleFn: func(echo.Context, model.AccessRole) error { + IsLowerRoleFn: func(echo.Context, gorsk.AccessRole) error { return echo.ErrForbidden }, }, @@ -403,19 +513,19 @@ func TestDeleteUser(t *testing.T) { name: "Success", id: `1`, udb: &mockdb.User{ - ViewFn: func(db orm.DB, id int) (*model.User, error) { - return &model.User{ - Role: &model.Role{ - AccessLevel: model.CompanyAdminRole, + ViewFn: func(db orm.DB, id int) (*gorsk.User, error) { + return &gorsk.User{ + Role: &gorsk.Role{ + AccessLevel: gorsk.CompanyAdminRole, }, }, nil }, - DeleteFn: func(orm.DB, *model.User) error { + DeleteFn: func(orm.DB, *gorsk.User) error { return nil }, }, rbac: &mock.RBAC{ - IsLowerRoleFn: func(echo.Context, model.AccessRole) error { + IsLowerRoleFn: func(echo.Context, gorsk.AccessRole) error { return nil }, }, @@ -428,11 +538,11 @@ func TestDeleteUser(t *testing.T) { for _, tt := range cases { t.Run(tt.name, func(t *testing.T) { r := server.New() - rg := r.Group("/v1/users") - service.NewUser(user.New(nil, tt.udb, tt.rbac, tt.auth), rg) + rg := r.Group("") + transport.NewHTTP(user.New(nil, tt.udb, tt.rbac, tt.sec), rg) ts := httptest.NewServer(r) defer ts.Close() - path := ts.URL + "/v1/users/" + tt.id + path := ts.URL + "/users/" + tt.id req, _ := http.NewRequest("DELETE", path, nil) res, err := client.Do(req) if err != nil { diff --git a/pkg/api/user/transport/swagger.go b/pkg/api/user/transport/swagger.go new file mode 100644 index 0000000..4fc36b0 --- /dev/null +++ b/pkg/api/user/transport/swagger.go @@ -0,0 +1,24 @@ +package transport + +import ( + "github.com/ribice/gorsk/pkg/utl/model" +) + +// User model response +// swagger:response userResp +type swaggUserResponse struct { + // in:body + Body struct { + *gorsk.User + } +} + +// Users model response +// swagger:response userListResp +type swaggUserListResponse struct { + // in:body + Body struct { + Users []gorsk.User `json:"users"` + Page int `json:"page"` + } +} diff --git a/pkg/api/user/user.go b/pkg/api/user/user.go new file mode 100644 index 0000000..a0cd24a --- /dev/null +++ b/pkg/api/user/user.go @@ -0,0 +1,77 @@ +// Package user contains user application services +package user + +import ( + "github.com/labstack/echo" + "github.com/ribice/gorsk/pkg/utl/model" + "github.com/ribice/gorsk/pkg/utl/query" + "github.com/ribice/gorsk/pkg/utl/structs" +) + +// Create creates a new user account +func (u *User) Create(c echo.Context, req gorsk.User) (*gorsk.User, error) { + if err := u.rbac.AccountCreate(c, req.RoleID, req.CompanyID, req.LocationID); err != nil { + return nil, err + } + req.Password = u.sec.Hash(req.Password) + return u.udb.Create(u.db, req) +} + +// List returns list of users +func (u *User) List(c echo.Context, p *gorsk.Pagination) ([]gorsk.User, error) { + au := u.rbac.User(c) + q, err := query.List(au) + if err != nil { + return nil, err + } + return u.udb.List(u.db, q, p) +} + +// View returns single user +func (u *User) View(c echo.Context, id int) (*gorsk.User, error) { + if err := u.rbac.EnforceUser(c, id); err != nil { + return nil, err + } + return u.udb.View(u.db, id) +} + +// Delete deletes a user +func (u *User) Delete(c echo.Context, id int) error { + user, err := u.udb.View(u.db, id) + if err != nil { + return err + } + if err := u.rbac.IsLowerRole(c, user.Role.AccessLevel); err != nil { + return err + } + return u.udb.Delete(u.db, user) +} + +// Update contains user's information used for updating +type Update struct { + ID int + FirstName *string + LastName *string + Mobile *string + Phone *string + Address *string +} + +// Update updates user's contact information +func (u *User) Update(c echo.Context, req *Update) (*gorsk.User, error) { + if err := u.rbac.EnforceUser(c, req.ID); err != nil { + return nil, err + } + + user, err := u.udb.View(u.db, req.ID) + if err != nil { + return nil, err + } + + structs.Merge(user, req) + if err := u.udb.Update(u.db, user); err != nil { + return nil, err + } + + return user, nil +} diff --git a/internal/user/user_test.go b/pkg/api/user/user_test.go similarity index 53% rename from internal/user/user_test.go rename to pkg/api/user/user_test.go index 22b0a7c..4b526b0 100644 --- a/internal/user/user_test.go +++ b/pkg/api/user/user_test.go @@ -3,17 +3,93 @@ package user_test import ( "testing" + "github.com/ribice/gorsk/pkg/api/user" + "github.com/ribice/gorsk/pkg/utl/mock" + "github.com/ribice/gorsk/pkg/utl/mock/mockdb" + "github.com/ribice/gorsk/pkg/utl/model" + "github.com/go-pg/pg/orm" "github.com/labstack/echo" "github.com/stretchr/testify/assert" - - "github.com/ribice/gorsk/internal" - "github.com/ribice/gorsk/internal/mock" - "github.com/ribice/gorsk/internal/mock/mockdb" - "github.com/ribice/gorsk/internal/user" ) +func TestCreate(t *testing.T) { + type args struct { + c echo.Context + req gorsk.User + } + cases := []struct { + name string + args args + wantErr bool + wantData *gorsk.User + udb *mockdb.User + rbac *mock.RBAC + sec *mock.Secure + }{{ + name: "Fail on is lower role", + rbac: &mock.RBAC{ + AccountCreateFn: func(echo.Context, gorsk.AccessRole, int, int) error { + return gorsk.ErrGeneric + }}, + wantErr: true, + args: args{req: gorsk.User{ + FirstName: "John", + LastName: "Doe", + Username: "JohnDoe", + RoleID: 1, + Password: "Thranduil8822", + }}, + }, + { + name: "Success", + args: args{req: gorsk.User{ + FirstName: "John", + LastName: "Doe", + Username: "JohnDoe", + RoleID: 1, + Password: "Thranduil8822", + }}, + udb: &mockdb.User{ + CreateFn: func(db orm.DB, u gorsk.User) (*gorsk.User, error) { + u.CreatedAt = mock.TestTime(2000) + u.UpdatedAt = mock.TestTime(2000) + u.Base.ID = 1 + return &u, nil + }, + }, + rbac: &mock.RBAC{ + AccountCreateFn: func(echo.Context, gorsk.AccessRole, int, int) error { + return nil + }}, + sec: &mock.Secure{ + HashFn: func(string) string { + return "h4$h3d" + }, + }, + wantData: &gorsk.User{ + Base: gorsk.Base{ + ID: 1, + CreatedAt: mock.TestTime(2000), + UpdatedAt: mock.TestTime(2000), + }, + FirstName: "John", + LastName: "Doe", + Username: "JohnDoe", + RoleID: 1, + Password: "h4$h3d", + }}} + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + s := user.New(nil, tt.udb, tt.rbac, tt.sec) + usr, err := s.Create(tt.args.c, tt.args.req) + assert.Equal(t, tt.wantErr, err != nil) + assert.Equal(t, tt.wantData, usr) + }) + } +} + func TestView(t *testing.T) { type args struct { c echo.Context @@ -22,7 +98,7 @@ func TestView(t *testing.T) { cases := []struct { name string args args - wantData *model.User + wantData *gorsk.User wantErr error udb *mockdb.User rbac *mock.RBAC @@ -32,15 +108,15 @@ func TestView(t *testing.T) { args: args{id: 5}, rbac: &mock.RBAC{ EnforceUserFn: func(c echo.Context, id int) error { - return model.ErrGeneric + return gorsk.ErrGeneric }}, - wantErr: model.ErrGeneric, + wantErr: gorsk.ErrGeneric, }, { name: "Success", args: args{id: 1}, - wantData: &model.User{ - Base: model.Base{ + wantData: &gorsk.User{ + Base: gorsk.Base{ ID: 1, CreatedAt: mock.TestTime(2000), UpdatedAt: mock.TestTime(2000), @@ -54,10 +130,10 @@ func TestView(t *testing.T) { return nil }}, udb: &mockdb.User{ - ViewFn: func(db orm.DB, id int) (*model.User, error) { + ViewFn: func(db orm.DB, id int) (*gorsk.User, error) { if id == 1 { - return &model.User{ - Base: model.Base{ + return &gorsk.User{ + Base: gorsk.Base{ ID: 1, CreatedAt: mock.TestTime(2000), UpdatedAt: mock.TestTime(2000), @@ -84,52 +160,52 @@ func TestView(t *testing.T) { func TestList(t *testing.T) { type args struct { c echo.Context - pgn *model.Pagination + pgn *gorsk.Pagination } cases := []struct { name string args args - wantData []model.User + wantData []gorsk.User wantErr bool udb *mockdb.User - auth *mock.Auth + rbac *mock.RBAC }{ { name: "Fail on query List", - args: args{c: nil, pgn: &model.Pagination{ + args: args{c: nil, pgn: &gorsk.Pagination{ Limit: 100, Offset: 200, }}, wantErr: true, - auth: &mock.Auth{ - UserFn: func(c echo.Context) *model.AuthUser { - return &model.AuthUser{ + rbac: &mock.RBAC{ + UserFn: func(c echo.Context) *gorsk.AuthUser { + return &gorsk.AuthUser{ ID: 1, CompanyID: 2, LocationID: 3, - Role: model.UserRole, + Role: gorsk.UserRole, } }}}, { name: "Success", - args: args{c: nil, pgn: &model.Pagination{ + args: args{c: nil, pgn: &gorsk.Pagination{ Limit: 100, Offset: 200, }}, - auth: &mock.Auth{ - UserFn: func(c echo.Context) *model.AuthUser { - return &model.AuthUser{ + rbac: &mock.RBAC{ + UserFn: func(c echo.Context) *gorsk.AuthUser { + return &gorsk.AuthUser{ ID: 1, CompanyID: 2, LocationID: 3, - Role: model.AdminRole, + Role: gorsk.AdminRole, } }}, udb: &mockdb.User{ - ListFn: func(orm.DB, *model.ListQuery, *model.Pagination) ([]model.User, error) { - return []model.User{ + ListFn: func(orm.DB, *gorsk.ListQuery, *gorsk.Pagination) ([]gorsk.User, error) { + return []gorsk.User{ { - Base: model.Base{ + Base: gorsk.Base{ ID: 1, CreatedAt: mock.TestTime(1999), UpdatedAt: mock.TestTime(2000), @@ -140,7 +216,7 @@ func TestList(t *testing.T) { Username: "johndoe", }, { - Base: model.Base{ + Base: gorsk.Base{ ID: 2, CreatedAt: mock.TestTime(2001), UpdatedAt: mock.TestTime(2002), @@ -152,9 +228,9 @@ func TestList(t *testing.T) { }, }, nil }}, - wantData: []model.User{ + wantData: []gorsk.User{ { - Base: model.Base{ + Base: gorsk.Base{ ID: 1, CreatedAt: mock.TestTime(1999), UpdatedAt: mock.TestTime(2000), @@ -165,7 +241,7 @@ func TestList(t *testing.T) { Username: "johndoe", }, { - Base: model.Base{ + Base: gorsk.Base{ ID: 2, CreatedAt: mock.TestTime(2001), UpdatedAt: mock.TestTime(2002), @@ -179,7 +255,7 @@ func TestList(t *testing.T) { } for _, tt := range cases { t.Run(tt.name, func(t *testing.T) { - s := user.New(nil, tt.udb, nil, tt.auth) + s := user.New(nil, tt.udb, tt.rbac, nil) usrs, err := s.List(tt.args.c, tt.args.pgn) assert.Equal(t, tt.wantData, usrs) assert.Equal(t, tt.wantErr, err != nil) @@ -203,13 +279,13 @@ func TestDelete(t *testing.T) { { name: "Fail on ViewUser", args: args{id: 1}, - wantErr: model.ErrGeneric, + wantErr: gorsk.ErrGeneric, udb: &mockdb.User{ - ViewFn: func(db orm.DB, id int) (*model.User, error) { + ViewFn: func(db orm.DB, id int) (*gorsk.User, error) { if id != 1 { return nil, nil } - return nil, model.ErrGeneric + return nil, gorsk.ErrGeneric }, }, }, @@ -217,53 +293,53 @@ func TestDelete(t *testing.T) { name: "Fail on RBAC", args: args{id: 1}, udb: &mockdb.User{ - ViewFn: func(db orm.DB, id int) (*model.User, error) { - return &model.User{ - Base: model.Base{ + ViewFn: func(db orm.DB, id int) (*gorsk.User, error) { + return &gorsk.User{ + Base: gorsk.Base{ ID: id, CreatedAt: mock.TestTime(1999), UpdatedAt: mock.TestTime(2000), }, FirstName: "John", LastName: "Doe", - Role: &model.Role{ - AccessLevel: model.UserRole, + Role: &gorsk.Role{ + AccessLevel: gorsk.UserRole, }, }, nil }, }, rbac: &mock.RBAC{ - IsLowerRoleFn: func(echo.Context, model.AccessRole) error { - return model.ErrGeneric + IsLowerRoleFn: func(echo.Context, gorsk.AccessRole) error { + return gorsk.ErrGeneric }}, - wantErr: model.ErrGeneric, + wantErr: gorsk.ErrGeneric, }, { name: "Success", args: args{id: 1}, udb: &mockdb.User{ - ViewFn: func(db orm.DB, id int) (*model.User, error) { - return &model.User{ - Base: model.Base{ + ViewFn: func(db orm.DB, id int) (*gorsk.User, error) { + return &gorsk.User{ + Base: gorsk.Base{ ID: id, CreatedAt: mock.TestTime(1999), UpdatedAt: mock.TestTime(2000), }, FirstName: "John", LastName: "Doe", - Role: &model.Role{ - AccessLevel: model.AdminRole, + Role: &gorsk.Role{ + AccessLevel: gorsk.AdminRole, ID: 2, Name: "Admin", }, }, nil }, - DeleteFn: func(db orm.DB, usr *model.User) error { + DeleteFn: func(db orm.DB, usr *gorsk.User) error { return nil }, }, rbac: &mock.RBAC{ - IsLowerRoleFn: func(echo.Context, model.AccessRole) error { + IsLowerRoleFn: func(echo.Context, gorsk.AccessRole) error { return nil }}, }, @@ -287,7 +363,7 @@ func TestUpdate(t *testing.T) { cases := []struct { name string args args - wantData *model.User + wantData *gorsk.User wantErr error udb *mockdb.User rbac *mock.RBAC @@ -299,9 +375,9 @@ func TestUpdate(t *testing.T) { }}, rbac: &mock.RBAC{ EnforceUserFn: func(c echo.Context, id int) error { - return model.ErrGeneric + return gorsk.ErrGeneric }}, - wantErr: model.ErrGeneric, + wantErr: gorsk.ErrGeneric, }, { name: "Fail on ViewUser", @@ -312,13 +388,47 @@ func TestUpdate(t *testing.T) { EnforceUserFn: func(c echo.Context, id int) error { return nil }}, - wantErr: model.ErrGeneric, + wantErr: gorsk.ErrGeneric, udb: &mockdb.User{ - ViewFn: func(db orm.DB, id int) (*model.User, error) { + ViewFn: func(db orm.DB, id int) (*gorsk.User, error) { if id != 1 { return nil, nil } - return nil, model.ErrGeneric + return nil, gorsk.ErrGeneric + }, + }, + }, + { + name: "Fail on Update", + args: args{upd: &user.Update{ + ID: 1, + }}, + rbac: &mock.RBAC{ + EnforceUserFn: func(c echo.Context, id int) error { + return nil + }}, + wantErr: gorsk.ErrGeneric, + udb: &mockdb.User{ + ViewFn: func(db orm.DB, id int) (*gorsk.User, error) { + return &gorsk.User{ + Base: gorsk.Base{ + ID: 1, + CreatedAt: mock.TestTime(1990), + UpdatedAt: mock.TestTime(1991), + }, + CompanyID: 1, + LocationID: 2, + RoleID: 3, + FirstName: "Joanna", + LastName: "Doep", + Mobile: "334455", + Phone: "444555", + Address: "Work Address", + Email: "golang@go.org", + }, nil + }, + UpdateFn: func(db orm.DB, usr *gorsk.User) error { + return gorsk.ErrGeneric }, }, }, @@ -335,8 +445,8 @@ func TestUpdate(t *testing.T) { EnforceUserFn: func(c echo.Context, id int) error { return nil }}, - wantData: &model.User{ - Base: model.Base{ + wantData: &gorsk.User{ + Base: gorsk.Base{ ID: 1, CreatedAt: mock.TestTime(1990), UpdatedAt: mock.TestTime(2000), @@ -352,30 +462,27 @@ func TestUpdate(t *testing.T) { Email: "golang@go.org", }, udb: &mockdb.User{ - ViewFn: func(db orm.DB, id int) (*model.User, error) { - if id == 1 { - return &model.User{ - Base: model.Base{ - ID: 1, - CreatedAt: mock.TestTime(1990), - UpdatedAt: mock.TestTime(1991), - }, - CompanyID: 1, - LocationID: 2, - RoleID: 3, - FirstName: "Joanna", - LastName: "Doep", - Mobile: "334455", - Phone: "444555", - Address: "Work Address", - Email: "golang@go.org", - }, nil - } - return nil, model.ErrGeneric + ViewFn: func(db orm.DB, id int) (*gorsk.User, error) { + return &gorsk.User{ + Base: gorsk.Base{ + ID: 1, + CreatedAt: mock.TestTime(1990), + UpdatedAt: mock.TestTime(1991), + }, + CompanyID: 1, + LocationID: 2, + RoleID: 3, + FirstName: "Joanna", + LastName: "Doep", + Mobile: "334455", + Phone: "444555", + Address: "Work Address", + Email: "golang@go.org", + }, nil }, - UpdateFn: func(db orm.DB, usr *model.User) (*model.User, error) { + UpdateFn: func(db orm.DB, usr *gorsk.User) error { usr.UpdatedAt = mock.TestTime(2000) - return usr, nil + return nil }, }, }, @@ -389,3 +496,10 @@ func TestUpdate(t *testing.T) { }) } } + +func TestInitialize(t *testing.T) { + u := user.Initialize(nil, nil, nil) + if u == nil { + t.Error("User service not initialized") + } +} diff --git a/pkg/utl/config/config.go b/pkg/utl/config/config.go new file mode 100644 index 0000000..1e50862 --- /dev/null +++ b/pkg/utl/config/config.go @@ -0,0 +1,59 @@ +package config + +import ( + "fmt" + "io/ioutil" + + yaml "gopkg.in/yaml.v2" +) + +// Load returns Configuration struct +func Load(path string) (*Configuration, error) { + bytes, err := ioutil.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("error reading config file, %s", err) + } + var cfg = new(Configuration) + if err := yaml.Unmarshal(bytes, cfg); err != nil { + return nil, fmt.Errorf("unable to decode into struct, %v", err) + } + return cfg, nil +} + +// Configuration holds data necessery for configuring application +type Configuration struct { + Server *Server `yaml:"server,omitempty"` + DB *Database `yaml:"database,omitempty"` + JWT *JWT `yaml:"jwt,omitempty"` + App *Application `yaml:"application,omitempty"` +} + +// Database holds data necessery for database configuration +type Database struct { + PSN string `yaml:"psn,omitempty"` + LogQueries bool `yaml:"log_queries,omitempty"` + Timeout int `yaml:"timeout_seconds,omitempty"` +} + +// Server holds data necessery for server configuration +type Server struct { + Port string `yaml:"port,omitempty"` + Debug bool `yaml:"debug,omitempty"` + ReadTimeout int `yaml:"read_timeout_seconds,omitempty"` + WriteTimeout int `yaml:"write_timeout_seconds,omitempty"` +} + +// JWT holds data necessery for JWT configuration +type JWT struct { + Secret string `yaml:"secret,omitempty"` + Duration int `yaml:"duration_minutes,omitempty"` + RefreshDuration int `yaml:"refresh_duration_minutes,omitempty"` + MaxRefresh int `yaml:"max_refresh_minutes,omitempty"` + SigningAlgorithm string `yaml:"signing_algorithm,omitempty"` +} + +// Application holds application configuration details +type Application struct { + MinPasswordStr int `yaml:"min_password_strength,omitempty"` + SwaggerUIPath string `yaml:"swagger_ui_path,omitempty"` +} diff --git a/cmd/api/config/config_test.go b/pkg/utl/config/config_test.go similarity index 50% rename from cmd/api/config/config_test.go rename to pkg/utl/config/config_test.go index a2f7c87..ae1080e 100644 --- a/cmd/api/config/config_test.go +++ b/pkg/utl/config/config_test.go @@ -3,52 +3,59 @@ package config_test import ( "testing" + "github.com/ribice/gorsk/pkg/utl/config" "github.com/stretchr/testify/assert" - - "github.com/ribice/gorsk/cmd/api/config" ) func TestLoad(t *testing.T) { - type args struct { - configName string - } cases := []struct { name string - args args + path string wantData *config.Configuration wantErr bool }{ { name: "Fail on non-existing file", - args: args{configName: "notExists"}, + path: "notExists", wantErr: true, }, { name: "Fail on wrong file format", - args: args{configName: "invalid"}, + path: "testdata/config.invalid.yaml", wantErr: true, }, { name: "Success", - args: args{configName: "testdata"}, + path: "testdata/config.testdata.yaml", wantData: &config.Configuration{ DB: &config.Database{ - Log: true, - CreateSchema: false, + PSN: "postgres://postgres:postgres@postgres", + LogQueries: true, + Timeout: 20, }, Server: &config.Server{ - Port: ":8080", - Debug: true, + Port: ":8080", + Debug: true, + ReadTimeout: 15, + WriteTimeout: 20, }, JWT: &config.JWT{ - Duration: 10800, + Secret: "testing", + Duration: 10, + RefreshDuration: 10, + MaxRefresh: 144, + SigningAlgorithm: "HS384", + }, + App: &config.Application{ + MinPasswordStr: 3, + SwaggerUIPath: "assets/swagger", }, }, }, } for _, tt := range cases { t.Run(tt.name, func(t *testing.T) { - cfg, err := config.Load(tt.args.configName) + cfg, err := config.Load(tt.path) assert.Equal(t, tt.wantData, cfg) assert.Equal(t, tt.wantErr, err != nil) }) diff --git a/cmd/api/config/files/config.invalid.yaml b/pkg/utl/config/testdata/config.invalid.yaml similarity index 100% rename from cmd/api/config/files/config.invalid.yaml rename to pkg/utl/config/testdata/config.invalid.yaml diff --git a/pkg/utl/config/testdata/config.testdata.yaml b/pkg/utl/config/testdata/config.testdata.yaml new file mode 100644 index 0000000..8ea6b03 --- /dev/null +++ b/pkg/utl/config/testdata/config.testdata.yaml @@ -0,0 +1,21 @@ +database: + log_queries: true + timeout_seconds: 20 + psn: postgres://postgres:postgres@postgres + +server: + port: :8080 + debug: true + read_timeout_seconds: 15 + write_timeout_seconds: 20 + +jwt: + secret: testing # Change this value + duration_minutes: 10 + refresh_duration_minutes: 10 + max_refresh_minutes: 144 + signing_algorithm: HS384 + +application: + min_password_strength: 3 + swagger_ui_path: assets/swagger \ No newline at end of file diff --git a/cmd/api/mw/jwt.go b/pkg/utl/middleware/jwt/jwt.go similarity index 58% rename from cmd/api/mw/jwt.go rename to pkg/utl/middleware/jwt/jwt.go index 7c4278a..f56dc77 100644 --- a/cmd/api/mw/jwt.go +++ b/pkg/utl/middleware/jwt/jwt.go @@ -1,41 +1,44 @@ -package mw +package jwt import ( "net/http" "strings" "time" - "github.com/labstack/echo" - "github.com/ribice/gorsk/internal" + "github.com/ribice/gorsk/pkg/utl/model" - "github.com/ribice/gorsk/cmd/api/config" + "github.com/labstack/echo" jwt "github.com/dgrijalva/jwt-go" ) -// NewJWT generates new JWT variable necessery for auth middleware -func NewJWT(c *config.JWT) *JWT { - return &JWT{ - Key: []byte(c.Secret), - Duration: time.Duration(c.Duration) * time.Minute, - Algo: c.SigningAlgorithm, +// New generates new JWT service necessery for auth middleware +func New(secret, algo string, d int) *Service { + signingMethod := jwt.GetSigningMethod(algo) + if signingMethod == nil { + panic("invalid jwt signing method") + } + return &Service{ + key: []byte(secret), + algo: signingMethod, + duration: time.Duration(d) * time.Minute, } } -// JWT provides a Json-Web-Token authentication implementation -type JWT struct { +// Service provides a Json-Web-Token authentication implementation +type Service struct { // Secret key used for signing. - Key []byte + key []byte // Duration for which the jwt token is valid. - Duration time.Duration + duration time.Duration // JWT signing algorithm - Algo string + algo jwt.SigningMethod } // MWFunc makes JWT implement the Middleware interface. -func (j *JWT) MWFunc() echo.MiddlewareFunc { +func (j *Service) MWFunc() echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { token, err := j.ParseToken(c) @@ -50,7 +53,7 @@ func (j *JWT) MWFunc() echo.MiddlewareFunc { locationID := int(claims["l"].(float64)) username := claims["u"].(string) email := claims["e"].(string) - role := model.AccessRole(claims["r"].(float64)) + role := gorsk.AccessRole(claims["r"].(float64)) c.Set("id", id) c.Set("company_id", companyID) @@ -65,31 +68,31 @@ func (j *JWT) MWFunc() echo.MiddlewareFunc { } // ParseToken parses token from Authorization header -func (j *JWT) ParseToken(c echo.Context) (*jwt.Token, error) { +func (j *Service) ParseToken(c echo.Context) (*jwt.Token, error) { token := c.Request().Header.Get("Authorization") if token == "" { - return nil, model.ErrGeneric + return nil, gorsk.ErrGeneric } parts := strings.SplitN(token, " ", 2) if !(len(parts) == 2 && parts[0] == "Bearer") { - return nil, model.ErrGeneric + return nil, gorsk.ErrGeneric } return jwt.Parse(parts[1], func(token *jwt.Token) (interface{}, error) { - if jwt.GetSigningMethod(j.Algo) != token.Method { - return nil, model.ErrGeneric + if j.algo != token.Method { + return nil, gorsk.ErrGeneric } - return j.Key, nil + return j.key, nil }) } // GenerateToken generates new JWT token and populates it with user data -func (j *JWT) GenerateToken(u *model.User) (string, string, error) { - expire := time.Now().Add(j.Duration) +func (j *Service) GenerateToken(u *gorsk.User) (string, string, error) { + expire := time.Now().Add(j.duration) - token := jwt.NewWithClaims(jwt.GetSigningMethod(j.Algo), jwt.MapClaims{ + token := jwt.NewWithClaims((j.algo), jwt.MapClaims{ "id": u.ID, "u": u.Username, "e": u.Email, @@ -99,7 +102,7 @@ func (j *JWT) GenerateToken(u *model.User) (string, string, error) { "exp": expire.Unix(), }) - tokenString, err := token.SignedString(j.Key) + tokenString, err := token.SignedString(j.key) return tokenString, expire.Format(time.RFC3339), err } diff --git a/cmd/api/mw/jwt_test.go b/pkg/utl/middleware/jwt/jwt_test.go similarity index 75% rename from cmd/api/mw/jwt_test.go rename to pkg/utl/middleware/jwt/jwt_test.go index e7d363d..0de7127 100644 --- a/cmd/api/mw/jwt_test.go +++ b/pkg/utl/middleware/jwt/jwt_test.go @@ -1,4 +1,4 @@ -package mw_test +package jwt_test import ( "net/http" @@ -6,21 +6,15 @@ import ( "strings" "testing" - "github.com/labstack/echo" - "github.com/stretchr/testify/assert" + "github.com/ribice/gorsk/pkg/utl/middleware/jwt" + "github.com/ribice/gorsk/pkg/utl/model" - "github.com/ribice/gorsk/internal" + "github.com/ribice/gorsk/pkg/utl/mock" - "github.com/ribice/gorsk/cmd/api/config" - "github.com/ribice/gorsk/internal/mock" - - "github.com/ribice/gorsk/cmd/api/mw" + "github.com/labstack/echo" + "github.com/stretchr/testify/assert" ) -func hwHandler(c echo.Context) error { - return c.String(200, "Hello World") -} - func echoHandler(mw ...echo.MiddlewareFunc) *echo.Echo { e := echo.New() for _, v := range mw { @@ -30,11 +24,16 @@ func echoHandler(mw ...echo.MiddlewareFunc) *echo.Echo { return e } +func hwHandler(c echo.Context) error { + return c.String(200, "Hello World") +} + func TestMWFunc(t *testing.T) { cases := []struct { name string wantStatus int header string + signMethod string }{ { name: "Empty header", @@ -56,8 +55,7 @@ func TestMWFunc(t *testing.T) { wantStatus: http.StatusOK, }, } - jwtCfg := &config.JWT{Secret: "jwtsecret", Duration: 60, SigningAlgorithm: "HS256"} - jwtMW := mw.NewJWT(jwtCfg) + jwtMW := jwt.New("jwtsecret", "HS256", 60) ts := httptest.NewServer(echoHandler(jwtMW.MWFunc())) defer ts.Close() path := ts.URL + "/hello" @@ -80,18 +78,24 @@ func TestGenerateToken(t *testing.T) { cases := []struct { name string wantToken string - req *model.User + algo string + req *gorsk.User }{ + { + name: "Invalid algo", + algo: "invalid", + }, { name: "Success", - req: &model.User{ - Base: model.Base{ + algo: "HS256", + req: &gorsk.User{ + Base: gorsk.Base{ ID: 1, }, Username: "johndoe", Email: "johndoe@mail.com", - Role: &model.Role{ - AccessLevel: model.SuperAdminRole, + Role: &gorsk.Role{ + AccessLevel: gorsk.SuperAdminRole, }, CompanyID: 1, LocationID: 1, @@ -99,11 +103,16 @@ func TestGenerateToken(t *testing.T) { wantToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9", }, } - jwtCfg := &config.JWT{Secret: "jwtsecret", Duration: 60, SigningAlgorithm: "HS256"} for _, tt := range cases { t.Run(tt.name, func(t *testing.T) { - jwt := mw.NewJWT(jwtCfg) + if tt.algo != "HS256" { + assert.Panics(t, func() { + jwt.New("jwtsecret", tt.algo, 60) + }, "The code did not panic") + return + } + jwt := jwt.New("jwtsecret", tt.algo, 60) str, _, err := jwt.GenerateToken(tt.req) assert.Nil(t, err) assert.Equal(t, tt.wantToken, strings.Split(str, ".")[0]) diff --git a/cmd/api/mw/mw.go b/pkg/utl/middleware/secure/secure.go similarity index 82% rename from cmd/api/mw/mw.go rename to pkg/utl/middleware/secure/secure.go index 1cd1df6..def7f45 100644 --- a/cmd/api/mw/mw.go +++ b/pkg/utl/middleware/secure/secure.go @@ -1,19 +1,12 @@ -package mw +package secure import ( "github.com/labstack/echo" "github.com/labstack/echo/middleware" ) -// Add adds middlewares to echo engine -func Add(r *echo.Echo, m ...echo.MiddlewareFunc) { - for _, v := range m { - r.Use(v) - } -} - -// SecureHeaders adds general security headers for basic security measures -func SecureHeaders() echo.MiddlewareFunc { +// Headers adds general security headers for basic security measures +func Headers() echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { // Protects from MimeType Sniffing diff --git a/cmd/api/mw/mw_test.go b/pkg/utl/middleware/secure/secure_test.go similarity index 75% rename from cmd/api/mw/mw_test.go rename to pkg/utl/middleware/secure/secure_test.go index 09d5017..3050ba8 100644 --- a/cmd/api/mw/mw_test.go +++ b/pkg/utl/middleware/secure/secure_test.go @@ -1,24 +1,31 @@ -package mw_test +package secure_test import ( "net/http" "net/http/httptest" "testing" + "github.com/ribice/gorsk/pkg/utl/middleware/secure" + "github.com/labstack/echo" - "github.com/labstack/echo/middleware" "github.com/stretchr/testify/assert" - - "github.com/ribice/gorsk/cmd/api/mw" ) -func TestAdd(t *testing.T) { +func echoHandler(mw ...echo.MiddlewareFunc) *echo.Echo { e := echo.New() - mw.Add(e, middleware.Logger()) + for _, v := range mw { + e.Use(v) + } + e.GET("/hello", hwHandler) + return e +} + +func hwHandler(c echo.Context) error { + return c.String(200, "Hello World") } func TestSecureHeaders(t *testing.T) { - ts := httptest.NewServer(echoHandler(mw.SecureHeaders())) + ts := httptest.NewServer(echoHandler(secure.Headers())) defer ts.Close() resp, err := http.Get(ts.URL + "/hello") if err != nil { @@ -32,8 +39,8 @@ func TestSecureHeaders(t *testing.T) { assert.Equal(t, "1; mode=block", resp.Header.Get("X-XSS-Protection")) } -func TestCors(t *testing.T) { - ts := httptest.NewServer(echoHandler(mw.CORS())) +func TestCORS(t *testing.T) { + ts := httptest.NewServer(echoHandler(secure.CORS())) defer ts.Close() var cl http.Client req, _ := http.NewRequest("OPTIONS", ts.URL+"/hello", nil) diff --git a/internal/mock/mock.go b/pkg/utl/mock/mock.go similarity index 76% rename from internal/mock/mock.go rename to pkg/utl/mock/mock.go index 35e651d..5562e86 100644 --- a/internal/mock/mock.go +++ b/pkg/utl/mock/mock.go @@ -1,13 +1,10 @@ package mock import ( - "net/http" "net/http/httptest" "time" - "github.com/go-playground/validator" "github.com/labstack/echo" - "github.com/ribice/gorsk/cmd/api/server" ) // TestTime is used for testing time fields @@ -26,6 +23,16 @@ func Str2Ptr(s string) *string { return &s } +// HeaderValid is used for jwt testing +func HeaderValid() string { + return "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidSI6ImpvaG5kb2UiLCJlIjoiam9obmRvZUBtYWlsLmNvbSIsInIiOjEsImMiOjEsImwiOjEsImV4cCI6NDEwOTMyMDg5NCwiaWF0IjoxNTE2MjM5MDIyfQ.8Fa8mhshx3tiQVzS5FoUXte5lHHC4cvaa_tzvcel38I" +} + +// HeaderInvalid is used for jwt testing +func HeaderInvalid() string { + return "Bearer eyJhbGciOiJIUzM4NCIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidSI6ImpvaG5kb2UiLCJlIjoiam9obmRvZUBtYWlsLmNvbSIsInIiOjEsImMiOjEsImwiOjEsImV4cCI6NDEwOTMyMDg5NCwiaWF0IjoxNTE2MjM5MDIyfQ.7uPfVeZBkkyhICZSEINZfPo7ZsaY0NNeg0ebEGHuAvNjFvoKNn8dWYTKaZrqE1X4" +} + // EchoCtxWithKeys returns new Echo context with keys func EchoCtxWithKeys(keys []string, values ...interface{}) echo.Context { e := echo.New() @@ -36,22 +43,3 @@ func EchoCtxWithKeys(keys []string, values ...interface{}) echo.Context { } return c } - -// EchoCtx returns new Echo context, with validator and content type set -func EchoCtx(r *http.Request, w http.ResponseWriter) echo.Context { - r.Header.Set("Content-Type", "application/json") - e := echo.New() - e.Validator = &server.CustomValidator{V: validator.New()} - e.Binder = &server.CustomBinder{} - return e.NewContext(r, w) -} - -// HeaderValid is used for jwt testing -func HeaderValid() string { - return "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidSI6ImpvaG5kb2UiLCJlIjoiam9obmRvZUBtYWlsLmNvbSIsInIiOjEsImMiOjEsImwiOjEsImV4cCI6NDEwOTMyMDg5NCwiaWF0IjoxNTE2MjM5MDIyfQ.8Fa8mhshx3tiQVzS5FoUXte5lHHC4cvaa_tzvcel38I" -} - -// HeaderInvalid is used for jwt testing -func HeaderInvalid() string { - return "Bearer eyJhbGciOiJIUzM4NCIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidSI6ImpvaG5kb2UiLCJlIjoiam9obmRvZUBtYWlsLmNvbSIsInIiOjEsImMiOjEsImwiOjEsImV4cCI6NDEwOTMyMDg5NCwiaWF0IjoxNTE2MjM5MDIyfQ.7uPfVeZBkkyhICZSEINZfPo7ZsaY0NNeg0ebEGHuAvNjFvoKNn8dWYTKaZrqE1X4" -} diff --git a/pkg/utl/mock/mockdb/user.go b/pkg/utl/mock/mockdb/user.go new file mode 100644 index 0000000..e23bf34 --- /dev/null +++ b/pkg/utl/mock/mockdb/user.go @@ -0,0 +1,52 @@ +package mockdb + +import ( + "github.com/go-pg/pg/orm" + "github.com/ribice/gorsk/pkg/utl/model" +) + +// User database mock +type User struct { + CreateFn func(orm.DB, gorsk.User) (*gorsk.User, error) + ViewFn func(orm.DB, int) (*gorsk.User, error) + FindByUsernameFn func(orm.DB, string) (*gorsk.User, error) + FindByTokenFn func(orm.DB, string) (*gorsk.User, error) + ListFn func(orm.DB, *gorsk.ListQuery, *gorsk.Pagination) ([]gorsk.User, error) + DeleteFn func(orm.DB, *gorsk.User) error + UpdateFn func(orm.DB, *gorsk.User) error +} + +// Create mock +func (u *User) Create(db orm.DB, usr gorsk.User) (*gorsk.User, error) { + return u.CreateFn(db, usr) +} + +// View mock +func (u *User) View(db orm.DB, id int) (*gorsk.User, error) { + return u.ViewFn(db, id) +} + +// FindByUsername mock +func (u *User) FindByUsername(db orm.DB, uname string) (*gorsk.User, error) { + return u.FindByUsernameFn(db, uname) +} + +// FindByToken mock +func (u *User) FindByToken(db orm.DB, token string) (*gorsk.User, error) { + return u.FindByTokenFn(db, token) +} + +// List mock +func (u *User) List(db orm.DB, lq *gorsk.ListQuery, p *gorsk.Pagination) ([]gorsk.User, error) { + return u.ListFn(db, lq, p) +} + +// Delete mock +func (u *User) Delete(db orm.DB, usr *gorsk.User) error { + return u.DeleteFn(db, usr) +} + +// Update mock +func (u *User) Update(db orm.DB, usr *gorsk.User) error { + return u.UpdateFn(db, usr) +} diff --git a/pkg/utl/mock/mw.go b/pkg/utl/mock/mw.go new file mode 100644 index 0000000..21d7e34 --- /dev/null +++ b/pkg/utl/mock/mw.go @@ -0,0 +1,15 @@ +package mock + +import ( + "github.com/ribice/gorsk/pkg/utl/model" +) + +// JWT mock +type JWT struct { + GenerateTokenFn func(*gorsk.User) (string, string, error) +} + +// GenerateToken mock +func (j *JWT) GenerateToken(u *gorsk.User) (string, string, error) { + return j.GenerateTokenFn(u) +} diff --git a/pkg/utl/mock/postgres.go b/pkg/utl/mock/postgres.go new file mode 100644 index 0000000..1f9eb3a --- /dev/null +++ b/pkg/utl/mock/postgres.go @@ -0,0 +1,54 @@ +package mock + +import ( + "database/sql" + "testing" + + "github.com/go-pg/pg/orm" + + "github.com/go-pg/pg" + "github.com/ribice/gorsk/pkg/utl/postgres" + + "github.com/fortytw2/dockertest" +) + +// NewPGContainer instantiates new PostgreSQL docker container +func NewPGContainer(t *testing.T) *dockertest.Container { + container, err := dockertest.RunContainer("postgres:alpine", "5432", func(addr string) error { + db, err := sql.Open("postgres", "postgres://postgres:postgres@"+addr+"?sslmode=disable") + fatalErr(t, err) + + return db.Ping() + }) + fatalErr(t, err) + + return container +} + +// NewDB instantiates new postgresql database connection via docker container +func NewDB(t *testing.T, con *dockertest.Container, models ...interface{}) *pg.DB { + db, err := postgres.New("postgres://postgres:postgres@"+con.Addr+"/postgres?sslmode=disable", 10, false) + fatalErr(t, err) + + for _, v := range models { + fatalErr(t, db.CreateTable(v, &orm.CreateTableOptions{FKConstraints: true})) + } + + return db +} + +// InsertMultiple inserts multiple values into database +func InsertMultiple(db *pg.DB, models ...interface{}) error { + for _, v := range models { + if err := db.Insert(v); err != nil { + return err + } + } + return nil +} + +func fatalErr(t *testing.T, err error) { + if err != nil { + t.Fatal(err) + } +} diff --git a/internal/mock/rbac.go b/pkg/utl/mock/rbac.go similarity index 60% rename from internal/mock/rbac.go rename to pkg/utl/mock/rbac.go index 80bfbef..9f09ae0 100644 --- a/internal/mock/rbac.go +++ b/pkg/utl/mock/rbac.go @@ -2,22 +2,27 @@ package mock import ( "github.com/labstack/echo" - - "github.com/ribice/gorsk/internal" + "github.com/ribice/gorsk/pkg/utl/model" ) // RBAC Mock type RBAC struct { - EnforceRoleFn func(echo.Context, model.AccessRole) error + UserFn func(echo.Context) *gorsk.AuthUser + EnforceRoleFn func(echo.Context, gorsk.AccessRole) error EnforceUserFn func(echo.Context, int) error EnforceCompanyFn func(echo.Context, int) error EnforceLocationFn func(echo.Context, int) error - AccountCreateFn func(echo.Context, model.AccessRole, int, int) error - IsLowerRoleFn func(echo.Context, model.AccessRole) error + AccountCreateFn func(echo.Context, gorsk.AccessRole, int, int) error + IsLowerRoleFn func(echo.Context, gorsk.AccessRole) error +} + +// User mock +func (a *RBAC) User(c echo.Context) *gorsk.AuthUser { + return a.UserFn(c) } // EnforceRole mock -func (a *RBAC) EnforceRole(c echo.Context, role model.AccessRole) error { +func (a *RBAC) EnforceRole(c echo.Context, role gorsk.AccessRole) error { return a.EnforceRoleFn(c, role) } @@ -37,11 +42,11 @@ func (a *RBAC) EnforceLocation(c echo.Context, id int) error { } // AccountCreate mock -func (a *RBAC) AccountCreate(c echo.Context, roleID model.AccessRole, companyID, locationID int) error { +func (a *RBAC) AccountCreate(c echo.Context, roleID gorsk.AccessRole, companyID, locationID int) error { return a.AccountCreateFn(c, roleID, companyID, locationID) } // IsLowerRole mock -func (a *RBAC) IsLowerRole(c echo.Context, role model.AccessRole) error { +func (a *RBAC) IsLowerRole(c echo.Context, role gorsk.AccessRole) error { return a.IsLowerRoleFn(c, role) } diff --git a/pkg/utl/mock/secure.go b/pkg/utl/mock/secure.go new file mode 100644 index 0000000..d864c8a --- /dev/null +++ b/pkg/utl/mock/secure.go @@ -0,0 +1,29 @@ +package mock + +// Secure mock +type Secure struct { + PasswordFn func(string, ...string) bool + HashFn func(string) string + HashMatchesPasswordFn func(string, string) bool + TokenFn func(string) string +} + +// Password mock +func (s *Secure) Password(pw string, inputs ...string) bool { + return s.PasswordFn(pw, inputs...) +} + +// Hash mock +func (s *Secure) Hash(pw string) string { + return s.HashFn(pw) +} + +// HashMatchesPassword mock +func (s *Secure) HashMatchesPassword(hash, pw string) bool { + return s.HashMatchesPasswordFn(hash, pw) +} + +// Token mock +func (s *Secure) Token(token string) string { + return s.TokenFn(token) +} diff --git a/internal/auth.go b/pkg/utl/model/auth.go similarity index 88% rename from internal/auth.go rename to pkg/utl/model/auth.go index 75c348c..6ec8569 100644 --- a/internal/auth.go +++ b/pkg/utl/model/auth.go @@ -1,4 +1,4 @@ -package model +package gorsk import ( "github.com/labstack/echo" @@ -17,13 +17,9 @@ type RefreshToken struct { Expires string `json:"expires"` } -// AuthService represents authentication service interface -type AuthService interface { - User(echo.Context) *AuthUser -} - // RBACService represents role-based access control service interface type RBACService interface { + User(echo.Context) *AuthUser EnforceRole(echo.Context, AccessRole) error EnforceUser(echo.Context, int) error EnforceCompany(echo.Context, int) error diff --git a/internal/company.go b/pkg/utl/model/company.go similarity index 94% rename from internal/company.go rename to pkg/utl/model/company.go index 9946548..5485806 100644 --- a/internal/company.go +++ b/pkg/utl/model/company.go @@ -1,4 +1,4 @@ -package model +package gorsk // Company represents company model type Company struct { diff --git a/pkg/utl/model/error.go b/pkg/utl/model/error.go new file mode 100644 index 0000000..da52518 --- /dev/null +++ b/pkg/utl/model/error.go @@ -0,0 +1,18 @@ +package gorsk + +import ( + "errors" + + "github.com/labstack/echo" +) + +var ( + // ErrGeneric is used for testing purposes and for errors handled later in the callstack + ErrGeneric = errors.New("generic error") + + // ErrBadRequest (400) is returned for bad request (validation) + ErrBadRequest = echo.NewHTTPError(400) + + // ErrUnauthorized (401) is returned when user is not authorized + ErrUnauthorized = echo.ErrUnauthorized +) diff --git a/internal/location.go b/pkg/utl/model/location.go similarity index 93% rename from internal/location.go rename to pkg/utl/model/location.go index 80df028..1b51091 100644 --- a/internal/location.go +++ b/pkg/utl/model/location.go @@ -1,4 +1,4 @@ -package model +package gorsk // Location represents company location model type Location struct { diff --git a/internal/model.go b/pkg/utl/model/model.go similarity index 58% rename from internal/model.go rename to pkg/utl/model/model.go index 739b9f7..917878a 100644 --- a/internal/model.go +++ b/pkg/utl/model/model.go @@ -1,27 +1,17 @@ -package model +package gorsk import ( - "errors" "time" "github.com/go-pg/pg/orm" ) -// ErrGeneric is used for testing purposes and for errors handled later in the callstack -var ErrGeneric = errors.New("generic error") - // Base contains common fields for all tables type Base struct { - ID int `json:"id"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt *time.Time `json:"deleted_at,omitempty" pg:",soft_delete"` -} - -// Pagination holds paginations data -type Pagination struct { - Limit int - Offset int + ID int `json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt time.Time `json:"deleted_at,omitempty" pg:",soft_delete"` } // ListQuery holds company/location data used for list db queries diff --git a/pkg/utl/model/model_test.go b/pkg/utl/model/model_test.go new file mode 100644 index 0000000..5743940 --- /dev/null +++ b/pkg/utl/model/model_test.go @@ -0,0 +1,57 @@ +package gorsk_test + +import ( + "testing" + + "github.com/ribice/gorsk/pkg/utl/mock" + "github.com/ribice/gorsk/pkg/utl/model" +) + +func TestBeforeInsert(t *testing.T) { + base := &gorsk.Base{ + ID: 1, + } + base.BeforeInsert(nil) + if base.CreatedAt.IsZero() { + t.Error("CreatedAt was not changed") + } + if base.UpdatedAt.IsZero() { + t.Error("UpdatedAt was not changed") + } +} + +func TestBeforeUpdate(t *testing.T) { + base := &gorsk.Base{ + ID: 1, + CreatedAt: mock.TestTime(2000), + } + base.BeforeUpdate(nil) + if base.UpdatedAt == mock.TestTime(2001) { + t.Error("UpdatedAt was not changed") + } + +} + +func TestPaginationTransform(t *testing.T) { + p := &gorsk.PaginationReq{ + Limit: 5000, Page: 5, + } + + pag := p.Transform() + + if pag.Limit != 1000 { + t.Error("Default limit not set") + } + + if pag.Offset != 5000 { + t.Error("Offset not set correctly") + } + + p.Limit = 0 + newPag := p.Transform() + + if newPag.Limit != 100 { + t.Error("Min limit not set") + } + +} diff --git a/pkg/utl/model/pagination.go b/pkg/utl/model/pagination.go new file mode 100644 index 0000000..51840da --- /dev/null +++ b/pkg/utl/model/pagination.go @@ -0,0 +1,32 @@ +package gorsk + +// Pagination constants +const ( + paginationDefaultLimit = 100 + paginationMaxLimit = 1000 +) + +// PaginationReq holds pagination http fields and tags +type PaginationReq struct { + Limit int `query:"limit"` + Page int `query:"page" validate:"min=0"` +} + +// Transform checks and converts http pagination into database pagination model +func (p *PaginationReq) Transform() *Pagination { + if p.Limit < 1 { + p.Limit = paginationDefaultLimit + } + + if p.Limit > paginationMaxLimit { + p.Limit = paginationMaxLimit + } + + return &Pagination{Limit: p.Limit, Offset: p.Page * p.Limit} +} + +// Pagination holds paginations data +type Pagination struct { + Limit int + Offset int +} diff --git a/internal/role.go b/pkg/utl/model/role.go similarity index 69% rename from internal/role.go rename to pkg/utl/model/role.go index baa216b..dcb6cfb 100644 --- a/internal/role.go +++ b/pkg/utl/model/role.go @@ -1,23 +1,23 @@ -package model +package gorsk // AccessRole represents access role type -type AccessRole int8 +type AccessRole int const ( // SuperAdminRole has all permissions - SuperAdminRole AccessRole = iota + 1 + SuperAdminRole AccessRole = 100 // AdminRole has admin specific permissions - AdminRole + AdminRole AccessRole = 110 // CompanyAdminRole can edit company specific things - CompanyAdminRole + CompanyAdminRole AccessRole = 120 // LocationAdminRole can edit location specific things - LocationAdminRole + LocationAdminRole AccessRole = 130 // UserRole is a standard user - UserRole + UserRole AccessRole = 200 ) // Role model diff --git a/cmd/api/swagger/swagger.go b/pkg/utl/model/swagger.go similarity index 94% rename from cmd/api/swagger/swagger.go rename to pkg/utl/model/swagger.go index bdc33f5..f20446f 100644 --- a/cmd/api/swagger/swagger.go +++ b/pkg/utl/model/swagger.go @@ -1,4 +1,4 @@ -package swagger +package gorsk // Success response // swagger:response ok diff --git a/pkg/utl/model/user.go b/pkg/utl/model/user.go new file mode 100644 index 0000000..a8b391f --- /dev/null +++ b/pkg/utl/model/user.go @@ -0,0 +1,54 @@ +package gorsk + +import ( + "time" +) + +// User represents user domain model +type User struct { + Base + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Username string `json:"username"` + Password string `json:"-"` + Email string `json:"email"` + + Mobile string `json:"mobile,omitempty"` + Phone string `json:"phone,omitempty"` + Address string `json:"address,omitempty"` + + Active bool `json:"active"` + + LastLogin time.Time `json:"last_login,omitempty"` + LastPasswordChange time.Time `json:"last_password_change,omitempty"` + + Token string `json:"-"` + + Role *Role `json:"role,omitempty"` + + RoleID AccessRole `json:"-"` + CompanyID int `json:"company_id"` + LocationID int `json:"location_id"` +} + +// AuthUser represents data stored in JWT token for user +type AuthUser struct { + ID int + CompanyID int + LocationID int + Username string + Email string + Role AccessRole +} + +// ChangePassword updates user's password related fields +func (u *User) ChangePassword(hash string) { + u.Password = hash + u.LastPasswordChange = time.Now() +} + +// UpdateLastLogin updates last login field +func (u *User) UpdateLastLogin(token string) { + u.Token = token + u.LastLogin = time.Now() +} diff --git a/pkg/utl/model/user_test.go b/pkg/utl/model/user_test.go new file mode 100644 index 0000000..d263108 --- /dev/null +++ b/pkg/utl/model/user_test.go @@ -0,0 +1,43 @@ +package gorsk_test + +import ( + "testing" + + "github.com/ribice/gorsk/pkg/utl/model" +) + +func TestChangePassword(t *testing.T) { + user := &gorsk.User{ + FirstName: "TestGuy", + } + + hashedPassword := "h4$h3D" + + user.ChangePassword(hashedPassword) + if user.LastPasswordChange.IsZero() { + t.Errorf("Last password change was not changed") + } + + if user.Password != hashedPassword { + t.Errorf("Password was not changed") + + } +} + +func TestUpdateLastLogin(t *testing.T) { + user := &gorsk.User{ + FirstName: "TestGuy", + } + + token := "helloWorld" + + user.UpdateLastLogin(token) + if user.LastLogin.IsZero() { + t.Errorf("Last login time was not changed") + } + + if user.Token != token { + t.Errorf("Token was not changed") + + } +} diff --git a/pkg/utl/postgres/pg.go b/pkg/utl/postgres/pg.go new file mode 100644 index 0000000..0dd6e0f --- /dev/null +++ b/pkg/utl/postgres/pg.go @@ -0,0 +1,39 @@ +package postgres + +import ( + "log" + "time" + + "github.com/go-pg/pg" + // DB adapter + _ "github.com/lib/pq" +) + +// New creates new database connection to a postgres database +func New(psn string, timeout int, enableLog bool) (*pg.DB, error) { + u, err := pg.ParseURL(psn) + if err != nil { + return nil, err + } + + db := pg.Connect(u) + + _, err = db.Exec("SELECT 1") + if err != nil { + return nil, err + } + + if timeout > 0 { + db.WithTimeout(time.Second * time.Duration(timeout)) + } + + if enableLog { + db.OnQueryProcessed(func(event *pg.QueryProcessedEvent) { + if query, err := event.FormattedQuery(); err == nil { + log.Printf("%s | %s", time.Since(event.StartTime), query) + } + }) + } + + return db, nil +} diff --git a/pkg/utl/postgres/pg_test.go b/pkg/utl/postgres/pg_test.go new file mode 100644 index 0000000..a0ddcf2 --- /dev/null +++ b/pkg/utl/postgres/pg_test.go @@ -0,0 +1,58 @@ +package postgres_test + +import ( + "database/sql" + "testing" + + "github.com/ribice/gorsk/pkg/utl/model" + + "github.com/stretchr/testify/assert" + + "github.com/ribice/gorsk/pkg/utl/postgres" + + "github.com/fortytw2/dockertest" +) + +func TestNew(t *testing.T) { + container, err := dockertest.RunContainer("postgres:alpine", "5432", func(addr string) error { + db, err := sql.Open("postgres", "postgres://postgres:postgres@"+addr+"?sslmode=disable") + if err != nil { + return err + } + + return db.Ping() + }) + defer container.Shutdown() + if err != nil { + t.Fatalf("could not start postgres, %s", err) + } + + _, err = postgres.New("PSN", 1, false) + if err == nil { + t.Error("Expected error") + } + + _, err = postgres.New("postgres://postgres:postgres@localhost:1234/postgres?sslmode=disable", 0, false) + if err == nil { + t.Error("Expected error") + } + + dbLogTest, err := postgres.New("postgres://postgres:postgres@"+container.Addr+"/postgres?sslmode=disable", 0, true) + if err != nil { + t.Fatalf("Error establishing connection %v", err) + } + dbLogTest.Close() + + db, err := postgres.New("postgres://postgres:postgres@"+container.Addr+"/postgres?sslmode=disable", 1, true) + if err != nil { + t.Fatalf("Error establishing connection %v", err) + } + + var user gorsk.User + db.Select(&user) + + assert.NotNil(t, db) + + db.Close() + +} diff --git a/pkg/utl/query/query.go b/pkg/utl/query/query.go new file mode 100644 index 0000000..b44c5c9 --- /dev/null +++ b/pkg/utl/query/query.go @@ -0,0 +1,20 @@ +package query + +import ( + "github.com/labstack/echo" + "github.com/ribice/gorsk/pkg/utl/model" +) + +// List prepares data for list queries +func List(u *gorsk.AuthUser) (*gorsk.ListQuery, error) { + switch true { + case u.Role <= gorsk.AdminRole: // user is SuperAdmin or Admin + return nil, nil + case u.Role == gorsk.CompanyAdminRole: + return &gorsk.ListQuery{Query: "company_id = ?", ID: u.CompanyID}, nil + case u.Role == gorsk.LocationAdminRole: + return &gorsk.ListQuery{Query: "location_id = ?", ID: u.LocationID}, nil + default: + return nil, echo.ErrForbidden + } +} diff --git a/internal/platform/query/query_test.go b/pkg/utl/query/query_test.go similarity index 61% rename from internal/platform/query/query_test.go rename to pkg/utl/query/query_test.go index ad23386..661b410 100644 --- a/internal/platform/query/query_test.go +++ b/pkg/utl/query/query_test.go @@ -3,55 +3,55 @@ package query_test import ( "testing" - "github.com/labstack/echo" + "github.com/ribice/gorsk/pkg/utl/model" - "github.com/ribice/gorsk/internal" + "github.com/labstack/echo" - "github.com/ribice/gorsk/internal/platform/query" + "github.com/ribice/gorsk/pkg/utl/query" "github.com/stretchr/testify/assert" ) func TestList(t *testing.T) { type args struct { - user *model.AuthUser + user *gorsk.AuthUser } cases := []struct { name string args args - wantData *model.ListQuery + wantData *gorsk.ListQuery wantErr error }{ { name: "Super admin user", - args: args{user: &model.AuthUser{ - Role: model.SuperAdminRole, + args: args{user: &gorsk.AuthUser{ + Role: gorsk.SuperAdminRole, }}, }, { name: "Company admin user", - args: args{user: &model.AuthUser{ - Role: model.CompanyAdminRole, + args: args{user: &gorsk.AuthUser{ + Role: gorsk.CompanyAdminRole, CompanyID: 1, }}, - wantData: &model.ListQuery{ + wantData: &gorsk.ListQuery{ Query: "company_id = ?", ID: 1}, }, { name: "Location admin user", - args: args{user: &model.AuthUser{ - Role: model.LocationAdminRole, + args: args{user: &gorsk.AuthUser{ + Role: gorsk.LocationAdminRole, CompanyID: 1, LocationID: 2, }}, - wantData: &model.ListQuery{ + wantData: &gorsk.ListQuery{ Query: "location_id = ?", ID: 2}, }, { name: "Normal user", - args: args{user: &model.AuthUser{ - Role: model.UserRole, + args: args{user: &gorsk.AuthUser{ + Role: gorsk.UserRole, }}, wantErr: echo.ErrForbidden, }, diff --git a/internal/rbac/rbac.go b/pkg/utl/rbac/rbac.go similarity index 62% rename from internal/rbac/rbac.go rename to pkg/utl/rbac/rbac.go index c8bd1a5..60e8af4 100644 --- a/internal/rbac/rbac.go +++ b/pkg/utl/rbac/rbac.go @@ -2,18 +2,16 @@ package rbac import ( "github.com/labstack/echo" - "github.com/ribice/gorsk/internal" + "github.com/ribice/gorsk/pkg/utl/model" ) // New creates new RBAC service -func New(udb model.UserDB) *Service { - return &Service{udb} +func New() *Service { + return &Service{} } // Service is RBAC application service -type Service struct { - udb model.UserDB -} +type Service struct{} func checkBool(b bool) error { if b { @@ -22,9 +20,27 @@ func checkBool(b bool) error { return echo.ErrForbidden } +// User returns user data stored in jwt token +func (s *Service) User(c echo.Context) *gorsk.AuthUser { + id := c.Get("id").(int) + companyID := c.Get("company_id").(int) + locationID := c.Get("location_id").(int) + user := c.Get("username").(string) + email := c.Get("email").(string) + role := c.Get("role").(gorsk.AccessRole) + return &gorsk.AuthUser{ + ID: id, + Username: user, + CompanyID: companyID, + LocationID: locationID, + Email: email, + Role: role, + } +} + // EnforceRole authorizes request by AccessRole -func (s *Service) EnforceRole(c echo.Context, r model.AccessRole) error { - return checkBool(!(c.Get("role").(model.AccessRole) > r)) +func (s *Service) EnforceRole(c echo.Context, r gorsk.AccessRole) error { + return checkBool(!(c.Get("role").(gorsk.AccessRole) > r)) } // EnforceUser checks whether the request to change user data is done by the same user @@ -44,7 +60,7 @@ func (s *Service) EnforceCompany(c echo.Context, ID int) error { if s.isAdmin(c) { return nil } - if err := s.EnforceRole(c, model.CompanyAdminRole); err != nil { + if err := s.EnforceRole(c, gorsk.CompanyAdminRole); err != nil { return err } return checkBool(c.Get("company_id").(int) == ID) @@ -56,32 +72,32 @@ func (s *Service) EnforceLocation(c echo.Context, ID int) error { if s.isCompanyAdmin(c) { return nil } - if err := s.EnforceRole(c, model.LocationAdminRole); err != nil { + if err := s.EnforceRole(c, gorsk.LocationAdminRole); err != nil { return err } return checkBool((c.Get("location_id").(int) == ID)) } func (s *Service) isAdmin(c echo.Context) bool { - return !(c.Get("role").(model.AccessRole) > model.AdminRole) + return !(c.Get("role").(gorsk.AccessRole) > gorsk.AdminRole) } func (s *Service) isCompanyAdmin(c echo.Context) bool { // Must query company ID in database for the given user - return !(c.Get("role").(model.AccessRole) > model.CompanyAdminRole) + return !(c.Get("role").(gorsk.AccessRole) > gorsk.CompanyAdminRole) } // AccountCreate performs auth check when creating a new account // Location admin cannot create accounts, needs to be fixed on EnforceLocation function -func (s *Service) AccountCreate(c echo.Context, roleID model.AccessRole, companyID, locationID int) error { +func (s *Service) AccountCreate(c echo.Context, roleID gorsk.AccessRole, companyID, locationID int) error { if err := s.EnforceLocation(c, locationID); err != nil { return err } - return s.IsLowerRole(c, model.AccessRole(roleID)) + return s.IsLowerRole(c, roleID) } // IsLowerRole checks whether the requesting user has higher role than the user it wants to change // Used for account creation/deletion -func (s *Service) IsLowerRole(c echo.Context, r model.AccessRole) error { - return checkBool(c.Get("role").(model.AccessRole) < r) +func (s *Service) IsLowerRole(c echo.Context, r gorsk.AccessRole) error { + return checkBool(c.Get("role").(gorsk.AccessRole) < r) } diff --git a/internal/rbac/rbac_test.go b/pkg/utl/rbac/rbac_test.go similarity index 67% rename from internal/rbac/rbac_test.go rename to pkg/utl/rbac/rbac_test.go index 7c32f12..be00e6d 100644 --- a/internal/rbac/rbac_test.go +++ b/pkg/utl/rbac/rbac_test.go @@ -3,26 +3,36 @@ package rbac_test import ( "testing" + "github.com/ribice/gorsk/pkg/utl/model" + + "github.com/ribice/gorsk/pkg/utl/mock" + "github.com/ribice/gorsk/pkg/utl/rbac" + "github.com/labstack/echo" "github.com/stretchr/testify/assert" - - "github.com/ribice/gorsk/internal" - "github.com/ribice/gorsk/internal/mock" - "github.com/ribice/gorsk/internal/rbac" ) -func TestNew(t *testing.T) { - rbacService := rbac.New(nil) - if rbacService == nil { - t.Error("RBAC Service not initialized") - } +func TestUser(t *testing.T) { + ctx := mock.EchoCtxWithKeys([]string{ + "id", "company_id", "location_id", "username", "email", "role"}, + 9, 15, 52, "ribice", "ribice@gmail.com", gorsk.SuperAdminRole) + wantUser := &gorsk.AuthUser{ + ID: 9, + Username: "ribice", + CompanyID: 15, + LocationID: 52, + Email: "ribice@gmail.com", + Role: gorsk.SuperAdminRole, + } + rbacSvc := rbac.New() + assert.Equal(t, wantUser, rbacSvc.User(ctx)) } func TestEnforceRole(t *testing.T) { type args struct { ctx echo.Context - role model.AccessRole + role gorsk.AccessRole } cases := []struct { name string @@ -31,18 +41,18 @@ func TestEnforceRole(t *testing.T) { }{ { name: "Not authorized", - args: args{ctx: mock.EchoCtxWithKeys([]string{"role"}, model.AccessRole(3)), role: model.SuperAdminRole}, + args: args{ctx: mock.EchoCtxWithKeys([]string{"role"}, gorsk.CompanyAdminRole), role: gorsk.SuperAdminRole}, wantErr: true, }, { name: "Authorized", - args: args{ctx: mock.EchoCtxWithKeys([]string{"role"}, model.AccessRole(0)), role: model.CompanyAdminRole}, + args: args{ctx: mock.EchoCtxWithKeys([]string{"role"}, gorsk.SuperAdminRole), role: gorsk.CompanyAdminRole}, wantErr: false, }, } for _, tt := range cases { t.Run(tt.name, func(t *testing.T) { - rbacSvc := rbac.New(nil) + rbacSvc := rbac.New() res := rbacSvc.EnforceRole(tt.args.ctx, tt.args.role) assert.Equal(t, tt.wantErr, res == echo.ErrForbidden) }) @@ -61,23 +71,23 @@ func TestEnforceUser(t *testing.T) { }{ { name: "Not same user, not an admin", - args: args{ctx: mock.EchoCtxWithKeys([]string{"id", "role"}, 15, model.AccessRole(3)), id: 122}, + args: args{ctx: mock.EchoCtxWithKeys([]string{"id", "role"}, 15, gorsk.LocationAdminRole), id: 122}, wantErr: true, }, { name: "Not same user, but admin", - args: args{ctx: mock.EchoCtxWithKeys([]string{"id", "role"}, 22, model.AccessRole(0)), id: 44}, + args: args{ctx: mock.EchoCtxWithKeys([]string{"id", "role"}, 22, gorsk.SuperAdminRole), id: 44}, wantErr: false, }, { name: "Same user", - args: args{ctx: mock.EchoCtxWithKeys([]string{"id", "role"}, 8, model.AccessRole(3)), id: 8}, + args: args{ctx: mock.EchoCtxWithKeys([]string{"id", "role"}, 8, gorsk.AdminRole), id: 8}, wantErr: false, }, } for _, tt := range cases { t.Run(tt.name, func(t *testing.T) { - rbacSvc := rbac.New(nil) + rbacSvc := rbac.New() res := rbacSvc.EnforceUser(tt.args.ctx, tt.args.id) assert.Equal(t, tt.wantErr, res == echo.ErrForbidden) }) @@ -96,28 +106,28 @@ func TestEnforceCompany(t *testing.T) { }{ { name: "Not same company, not an admin", - args: args{ctx: mock.EchoCtxWithKeys([]string{"company_id", "role"}, 7, model.AccessRole(5)), id: 9}, + args: args{ctx: mock.EchoCtxWithKeys([]string{"company_id", "role"}, 7, gorsk.UserRole), id: 9}, wantErr: true, }, { name: "Same company, not company admin or admin", - args: args{ctx: mock.EchoCtxWithKeys([]string{"company_id", "role"}, 22, model.AccessRole(5)), id: 22}, + args: args{ctx: mock.EchoCtxWithKeys([]string{"company_id", "role"}, 22, gorsk.UserRole), id: 22}, wantErr: true, }, { name: "Same company, company admin", - args: args{ctx: mock.EchoCtxWithKeys([]string{"company_id", "role"}, 5, model.AccessRole(3)), id: 5}, + args: args{ctx: mock.EchoCtxWithKeys([]string{"company_id", "role"}, 5, gorsk.CompanyAdminRole), id: 5}, wantErr: false, }, { name: "Not same company but admin", - args: args{ctx: mock.EchoCtxWithKeys([]string{"company_id", "role"}, 8, model.AccessRole(2)), id: 9}, + args: args{ctx: mock.EchoCtxWithKeys([]string{"company_id", "role"}, 8, gorsk.AdminRole), id: 9}, wantErr: false, }, } for _, tt := range cases { t.Run(tt.name, func(t *testing.T) { - rbacSvc := rbac.New(nil) + rbacSvc := rbac.New() res := rbacSvc.EnforceCompany(tt.args.ctx, tt.args.id) assert.Equal(t, tt.wantErr, res == echo.ErrForbidden) }) @@ -136,28 +146,28 @@ func TestEnforceLocation(t *testing.T) { }{ { name: "Not same location, not an admin", - args: args{ctx: mock.EchoCtxWithKeys([]string{"location_id", "role"}, 7, model.AccessRole(5)), id: 9}, + args: args{ctx: mock.EchoCtxWithKeys([]string{"location_id", "role"}, 7, gorsk.UserRole), id: 9}, wantErr: true, }, { name: "Same location, not company admin or admin", - args: args{ctx: mock.EchoCtxWithKeys([]string{"location_id", "role"}, 22, model.AccessRole(5)), id: 22}, + args: args{ctx: mock.EchoCtxWithKeys([]string{"location_id", "role"}, 22, gorsk.UserRole), id: 22}, wantErr: true, }, { name: "Same location, company admin", - args: args{ctx: mock.EchoCtxWithKeys([]string{"location_id", "role"}, 5, model.AccessRole(3)), id: 5}, + args: args{ctx: mock.EchoCtxWithKeys([]string{"location_id", "role"}, 5, gorsk.CompanyAdminRole), id: 5}, wantErr: false, }, { name: "Location admin", - args: args{ctx: mock.EchoCtxWithKeys([]string{"location_id", "role"}, 5, model.AccessRole(4)), id: 5}, + args: args{ctx: mock.EchoCtxWithKeys([]string{"location_id", "role"}, 5, gorsk.LocationAdminRole), id: 5}, wantErr: false, }, } for _, tt := range cases { t.Run(tt.name, func(t *testing.T) { - rbacSvc := rbac.New(nil) + rbacSvc := rbac.New() res := rbacSvc.EnforceLocation(tt.args.ctx, tt.args.id) assert.Equal(t, tt.wantErr, res == echo.ErrForbidden) }) @@ -167,7 +177,7 @@ func TestEnforceLocation(t *testing.T) { func TestAccountCreate(t *testing.T) { type args struct { ctx echo.Context - roleID model.AccessRole + roleID gorsk.AccessRole company_id int location_id int } @@ -178,38 +188,38 @@ func TestAccountCreate(t *testing.T) { }{ { name: "Different location, company, creating user role, not an admin", - args: args{ctx: mock.EchoCtxWithKeys([]string{"company_id", "location_id", "role"}, 2, 3, model.AccessRole(5)), roleID: 5, company_id: 7, location_id: 8}, + args: args{ctx: mock.EchoCtxWithKeys([]string{"company_id", "location_id", "role"}, 2, 3, gorsk.UserRole), roleID: 500, company_id: 7, location_id: 8}, wantErr: true, }, { name: "Same location, not company, creating user role, not an admin", - args: args{ctx: mock.EchoCtxWithKeys([]string{"company_id", "location_id", "role"}, 2, 3, model.AccessRole(5)), roleID: 5, company_id: 2, location_id: 8}, + args: args{ctx: mock.EchoCtxWithKeys([]string{"company_id", "location_id", "role"}, 2, 3, gorsk.UserRole), roleID: 500, company_id: 2, location_id: 8}, wantErr: true, }, { name: "Different location, company, creating user role, not an admin", - args: args{ctx: mock.EchoCtxWithKeys([]string{"company_id", "location_id", "role"}, 2, 3, model.AccessRole(3)), roleID: 4, company_id: 2, location_id: 4}, + args: args{ctx: mock.EchoCtxWithKeys([]string{"company_id", "location_id", "role"}, 2, 3, gorsk.CompanyAdminRole), roleID: 400, company_id: 2, location_id: 4}, wantErr: false, }, { name: "Same location, company, creating user role, not an admin", - args: args{ctx: mock.EchoCtxWithKeys([]string{"company_id", "location_id", "role"}, 2, 3, model.AccessRole(3)), roleID: 5, company_id: 2, location_id: 3}, + args: args{ctx: mock.EchoCtxWithKeys([]string{"company_id", "location_id", "role"}, 2, 3, gorsk.CompanyAdminRole), roleID: 500, company_id: 2, location_id: 3}, wantErr: false, }, { name: "Same location, company, creating user role, admin", - args: args{ctx: mock.EchoCtxWithKeys([]string{"company_id", "location_id", "role"}, 2, 3, model.AccessRole(3)), roleID: 5, company_id: 2, location_id: 3}, + args: args{ctx: mock.EchoCtxWithKeys([]string{"company_id", "location_id", "role"}, 2, 3, gorsk.CompanyAdminRole), roleID: 500, company_id: 2, location_id: 3}, wantErr: false, }, { name: "Different everything, admin", - args: args{ctx: mock.EchoCtxWithKeys([]string{"company_id", "location_id", "role"}, 2, 3, model.AccessRole(1)), roleID: 2, company_id: 7, location_id: 4}, + args: args{ctx: mock.EchoCtxWithKeys([]string{"company_id", "location_id", "role"}, 2, 3, gorsk.AdminRole), roleID: 200, company_id: 7, location_id: 4}, wantErr: false, }, } for _, tt := range cases { t.Run(tt.name, func(t *testing.T) { - rbacSvc := rbac.New(nil) + rbacSvc := rbac.New() res := rbacSvc.AccountCreate(tt.args.ctx, tt.args.roleID, tt.args.company_id, tt.args.location_id) assert.Equal(t, tt.wantErr, res == echo.ErrForbidden) }) @@ -217,12 +227,12 @@ func TestAccountCreate(t *testing.T) { } func TestIsLowerRole(t *testing.T) { - ctx := mock.EchoCtxWithKeys([]string{"role"}, model.AccessRole(3)) - rbacSvc := rbac.New(nil) - if rbacSvc.IsLowerRole(ctx, model.AccessRole(4)) != nil { + ctx := mock.EchoCtxWithKeys([]string{"role"}, gorsk.CompanyAdminRole) + rbacSvc := rbac.New() + if rbacSvc.IsLowerRole(ctx, gorsk.LocationAdminRole) != nil { t.Error("The requested user is higher role than the user requesting it") } - if rbacSvc.IsLowerRole(ctx, model.AccessRole(2)) == nil { + if rbacSvc.IsLowerRole(ctx, gorsk.AdminRole) == nil { t.Error("The requested user is lower role than the user requesting it") } } diff --git a/pkg/utl/secure/secure.go b/pkg/utl/secure/secure.go new file mode 100644 index 0000000..cdfe5e0 --- /dev/null +++ b/pkg/utl/secure/secure.go @@ -0,0 +1,46 @@ +package secure + +import ( + "fmt" + "hash" + "strconv" + "time" + + zxcvbn "github.com/nbutton23/zxcvbn-go" + "golang.org/x/crypto/bcrypt" +) + +// New initalizes security service +func New(minPWStr int, h hash.Hash) *Service { + return &Service{minPWStr: minPWStr, h: h} +} + +// Service holds security related methods +type Service struct { + minPWStr int + h hash.Hash +} + +// Password checks whether password is secure enough using zxcvbn library +func (s *Service) Password(pass string, inputs ...string) bool { + pwStrength := zxcvbn.PasswordStrength(pass, inputs) + return pwStrength.Score >= s.minPWStr +} + +// Hash hashes the password using bcrypt +func (*Service) Hash(password string) string { + hashedPW, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + return string(hashedPW) +} + +// HashMatchesPassword matches hash with password. Returns true if hash and password match. +func (*Service) HashMatchesPassword(hash, password string) bool { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil +} + +// Token generates new unique token +func (s *Service) Token(str string) string { + s.h.Reset() + fmt.Fprintf(s.h, "%s%s", str, strconv.Itoa(time.Now().Nanosecond())) + return fmt.Sprintf("%x", s.h.Sum(nil)) +} diff --git a/pkg/utl/secure/secure_test.go b/pkg/utl/secure/secure_test.go new file mode 100644 index 0000000..f95a399 --- /dev/null +++ b/pkg/utl/secure/secure_test.go @@ -0,0 +1,71 @@ +package secure_test + +import ( + "crypto/sha1" + "testing" + + "github.com/ribice/gorsk/pkg/utl/secure" + "github.com/stretchr/testify/assert" +) + +func TestPassword(t *testing.T) { + cases := []struct { + name string + pass string + inputs []string + want bool + }{ + { + name: "Insecure password", + pass: "notSec", + want: false, + }, + { + name: "Password matches input fields", + pass: "johndoe92", + inputs: []string{"John", "Doe"}, + want: false, + }, + { + name: "Secure password", + pass: "callgophers", + inputs: []string{"John", "Doe"}, + want: true, + }, + } + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + s := secure.New(1, nil) + got := s.Password(tt.pass, tt.inputs...) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestHashAndMatch(t *testing.T) { + cases := []struct { + name string + pass string + want bool + }{ + { + name: "Success", + pass: "gamepad", + want: true, + }, + } + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + s := secure.New(1, nil) + hash := s.Hash(tt.pass) + assert.Equal(t, tt.want, s.HashMatchesPassword(hash, tt.pass)) + }) + } +} + +func TestToken(t *testing.T) { + s := secure.New(1, sha1.New()) + token := "token" + tokenized := s.Token(token) + assert.NotEqual(t, tokenized, token) +} diff --git a/cmd/api/server/binding.go b/pkg/utl/server/binding.go similarity index 67% rename from cmd/api/server/binding.go rename to pkg/utl/server/binding.go index 8c13b6a..f14a319 100644 --- a/cmd/api/server/binding.go +++ b/pkg/utl/server/binding.go @@ -5,14 +5,19 @@ import ( "github.com/labstack/echo" ) +// NewBinder initializes custom server binder +func NewBinder() *CustomBinder { + return &CustomBinder{b: &echo.DefaultBinder{}} +} + // CustomBinder struct -type CustomBinder struct{} +type CustomBinder struct { + b echo.Binder +} // Bind tries to bind request into interface, and if it does then validate it func (cb *CustomBinder) Bind(i interface{}, c echo.Context) error { - // You may use default binder - db := new(echo.DefaultBinder) - if err := db.Bind(i, c); err != nil && err != echo.ErrUnsupportedMediaType { + if err := cb.b.Bind(i, c); err != nil && err != echo.ErrUnsupportedMediaType { return err } return c.Validate(i) diff --git a/cmd/api/server/binding_test.go b/pkg/utl/server/binding_test.go similarity index 89% rename from cmd/api/server/binding_test.go rename to pkg/utl/server/binding_test.go index 9a49d2a..f2971c3 100644 --- a/cmd/api/server/binding_test.go +++ b/pkg/utl/server/binding_test.go @@ -6,8 +6,8 @@ import ( "net/http/httptest" "testing" - "github.com/ribice/gorsk/cmd/api/server" - "github.com/ribice/gorsk/internal/mock" + "github.com/ribice/gorsk/pkg/utl/mock" + "github.com/ribice/gorsk/pkg/utl/server" "github.com/stretchr/testify/assert" ) @@ -39,7 +39,7 @@ func TestBind(t *testing.T) { wantData: &Req{Name: "John"}, }, } - b := &server.CustomBinder{} + b := server.NewBinder() for _, tt := range cases { t.Run(tt.name, func(t *testing.T) { w := httptest.NewRecorder() diff --git a/cmd/api/server/error.go b/pkg/utl/server/error.go similarity index 100% rename from cmd/api/server/error.go rename to pkg/utl/server/error.go diff --git a/cmd/api/server/server.go b/pkg/utl/server/server.go similarity index 64% rename from cmd/api/server/server.go rename to pkg/utl/server/server.go index 92866bf..f0c209c 100644 --- a/cmd/api/server/server.go +++ b/pkg/utl/server/server.go @@ -9,8 +9,7 @@ import ( "github.com/go-playground/validator" "github.com/labstack/echo/middleware" - "github.com/ribice/gorsk/cmd/api/config" - "github.com/ribice/gorsk/cmd/api/mw" + "github.com/ribice/gorsk/pkg/utl/middleware/secure" "github.com/labstack/echo" ) @@ -18,13 +17,13 @@ import ( // New instantates new Echo server func New() *echo.Echo { e := echo.New() - mw.Add(e, middleware.Logger(), middleware.Recover(), - mw.CORS(), mw.SecureHeaders()) + e.Use(middleware.Logger(), middleware.Recover(), + secure.CORS(), secure.Headers()) e.GET("/", healthCheck) e.Validator = &CustomValidator{V: validator.New()} custErr := &customErrHandler{e: e} e.HTTPErrorHandler = custErr.handler - e.Binder = &CustomBinder{} + e.Binder = &CustomBinder{b: &echo.DefaultBinder{}} return e } @@ -32,12 +31,20 @@ func healthCheck(c echo.Context) error { return c.JSON(http.StatusOK, "OK") } -// Start starts echo server handling graceful shutdown, needs go1.8+. -func Start(e *echo.Echo, cfg *config.Server) { +// Config represents server specific config +type Config struct { + Port string + ReadTimeoutSeconds int + WriteTimeoutSeconds int + Debug bool +} + +// Start starts echo server +func Start(e *echo.Echo, cfg *Config) { s := &http.Server{ Addr: cfg.Port, - ReadTimeout: time.Duration(cfg.ReadTimeout) * time.Minute, - WriteTimeout: time.Duration(cfg.WriteTimeout) * time.Minute, + ReadTimeout: time.Duration(cfg.ReadTimeoutSeconds) * time.Second, + WriteTimeout: time.Duration(cfg.WriteTimeoutSeconds) * time.Second, } e.Debug = cfg.Debug diff --git a/cmd/api/server/server_test.go b/pkg/utl/server/server_test.go similarity index 80% rename from cmd/api/server/server_test.go rename to pkg/utl/server/server_test.go index e5621ef..b3b1071 100644 --- a/cmd/api/server/server_test.go +++ b/pkg/utl/server/server_test.go @@ -3,7 +3,7 @@ package server_test import ( "testing" - "github.com/ribice/gorsk/cmd/api/server" + "github.com/ribice/gorsk/pkg/utl/server" ) // Improve tests diff --git a/internal/platform/structs/merge.go b/pkg/utl/structs/merge.go similarity index 100% rename from internal/platform/structs/merge.go rename to pkg/utl/structs/merge.go diff --git a/internal/platform/structs/merge_test.go b/pkg/utl/structs/merge_test.go similarity index 99% rename from internal/platform/structs/merge_test.go rename to pkg/utl/structs/merge_test.go index a2d7fed..0a19727 100644 --- a/internal/platform/structs/merge_test.go +++ b/pkg/utl/structs/merge_test.go @@ -3,7 +3,7 @@ package structs_test import ( "testing" - "github.com/ribice/gorsk/internal/platform/structs" + "github.com/ribice/gorsk/pkg/utl/structs" "github.com/stretchr/testify/assert" ) diff --git a/test.sh b/test.sh index f34783f..47167ba 100755 --- a/test.sh +++ b/test.sh @@ -3,7 +3,7 @@ set -e echo "" > coverage.txt -for d in $(go list ./... | grep -v -e internal/mock -e cmd/api/server); do +for d in $(go list ./... | grep -v -e pkg/utl/mock -e pkg/utl/server); do go test -race -coverprofile=profile.out -covermode=atomic "$d" if [ -f profile.out ]; then cat profile.out >> coverage.txt