├── .circleci └── config.yml ├── .env.sample ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ └── show-example.md ├── PULL_REQUEST_TEMPLATE.md └── dependabot.yml ├── .gitignore ├── .mergify.yml ├── .vscode └── launch.json ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── apperr └── apperr.go ├── cmd ├── create_db.go ├── create_schema.go ├── create_superadmin.go ├── generate_secret.go ├── migrate.go ├── migrate_down.go ├── migrate_init.go ├── migrate_reset.go ├── migrate_set_version.go ├── migrate_up.go ├── migrate_version.go └── root.go ├── config ├── config.go ├── files │ ├── config.dev.yaml │ ├── config.prod.yaml │ ├── config.staging.yaml │ ├── config.test-e2e.yaml │ ├── config.test-int.yaml │ └── config.test.yaml ├── jwt.go ├── mail.go ├── postgres.go ├── site.go └── twilio.go ├── e2e ├── e2e.go ├── e2e_i_test.go ├── login_i_test.go ├── mobile_i_test.go └── signupemail_i_test.go ├── example └── main.go ├── gjango.go ├── go.mod ├── go.sum ├── mail ├── mail.go └── mail_interface.go ├── manager ├── createdb.go ├── createdbuser.go ├── createschema.go └── manager.go ├── middleware ├── jwt.go ├── jwt_test.go ├── middleware.go └── middleware_test.go ├── migration └── migration.go ├── mobile ├── mobile.go └── mobile_interface.go ├── mock ├── auth.go ├── mail.go ├── middleware.go ├── mobile.go ├── mock.go ├── mockdb │ ├── account.go │ └── user.go ├── rbac.go └── secret.go ├── mockgopg ├── build_insert.go ├── build_query.go ├── formatter.go ├── mock.go ├── orm.go └── row_result.go ├── model ├── auth.go ├── company.go ├── location.go ├── model.go ├── model_test.go ├── role.go ├── user.go ├── user_test.go └── verification.go ├── repository ├── account.go ├── account │ └── account.go ├── account_i_test.go ├── account_test.go ├── auth │ └── auth.go ├── platform │ ├── query │ │ └── query.go │ └── structs │ │ └── structs.go ├── rbac.go ├── rbac_i_test.go ├── role.go ├── user.go ├── user │ └── user.go ├── user_i_test.go └── user_test.go ├── request ├── account.go ├── auth.go ├── request.go ├── signup.go └── user.go ├── route ├── custom_route.go └── route.go ├── secret ├── cryptorandom.go ├── secret.go └── secret_interface.go ├── server └── server.go ├── service ├── account.go ├── account_test.go ├── auth.go ├── auth_test.go ├── user.go └── user_test.go ├── test.sh └── testhelper └── testhelper.go /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | defaults: &defaults 2 | docker: 3 | - image: circleci/golang:1.13 4 | environment: 5 | CIRCLECI: 1 6 | - image: circleci/postgres:12.2 7 | environment: 8 | POSTGRES_USER: db_test_user 9 | POSTGRES_PASSWORD: db_test_password 10 | POSTGRES_DB: db_test_database 11 | working_directory: /go/src/github.com/{{ORG_NAME}}/{{REPO_NAME}} 12 | 13 | version: 2 14 | jobs: 15 | checkout: 16 | <<: *defaults 17 | steps: 18 | - checkout 19 | - attach_workspace: 20 | at: /go/src/github.com/{{ORG_NAME}}/{{REPO_NAME}} 21 | - restore_cache: 22 | keys: 23 | - go-mod-v4-{{ checksum "go.sum" }} 24 | - run: 25 | name: Dependencies 26 | command: go get -v -t -d ./... 27 | - save_cache: 28 | key: go-mod-v4-{{ checksum "go.sum" }} 29 | paths: 30 | - "/go/pkg/mod" 31 | - persist_to_workspace: 32 | root: . 33 | paths: . 34 | 35 | unit-test: 36 | <<: *defaults 37 | steps: 38 | - attach_workspace: 39 | at: /go/src/github.com/{{ORG_NAME}}/{{REPO_NAME}} 40 | - run: 41 | name: Unit Tests 42 | command: | 43 | ./test.sh -s 44 | 45 | full-test: 46 | <<: *defaults 47 | steps: 48 | - attach_workspace: 49 | at: /go/src/github.com/{{ORG_NAME}}/{{REPO_NAME}} 50 | # Wait for Postgres to be ready before proceeding 51 | - run: 52 | name: Waiting for Postgres to be ready 53 | command: dockerize -wait tcp://localhost:5432 -timeout 1m 54 | - run: 55 | name: Tests with Coverage 56 | command: | 57 | curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter 58 | chmod +x ./cc-test-reporter 59 | ./cc-test-reporter before-build 60 | go test -coverprofile c.out ./... 61 | ./cc-test-reporter after-build -t gocov -p github.com/gogjango/gjango -r ${TEST_REPORTER_ID} 62 | 63 | workflows: 64 | version: 2 65 | test-deploy-purge: 66 | jobs: 67 | - checkout 68 | - unit-test: 69 | filters: 70 | branches: 71 | ignore: 72 | - master 73 | - develop 74 | requires: 75 | - checkout 76 | - full-test: 77 | filters: 78 | branches: 79 | only: 80 | - master 81 | - develop 82 | requires: 83 | - checkout 84 | -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | export POSTGRES_HOST=localhost # for local development 2 | # export POSTGRES_HOST=postgres # for docker/kubernetes 3 | export POSTGRES_PORT=5432 4 | export POSTGRES_USER=test_user 5 | export POSTGRES_PASSWORD=test_password 6 | export POSTGRES_DB=test_db 7 | 8 | # DATABASE_URL will be used in preference if it exists 9 | # export DATABASE_URL=postgres://$POSTGRES_USER:$POSTGRES_PASSWORD@$POSTGRES_HOST:$POSTGRES_PORT/$POSTGRES_DB 10 | export DATABASE_URL=$DATABASE_URL 11 | 12 | # these are needed to create the database 13 | # and create the postgres user/password initially 14 | # if they are not set in env, these are the default values 15 | export POSTGRES_SUPERUSER=postgres 16 | export POSTGRES_SUPERUSER_PASSWORD= 17 | export POSTGRES_SUPERUSER_DB=postgres 18 | 19 | # for transactional emails 20 | export SENDGRID_API_KEY= 21 | export DEFAULT_NAME= 22 | export DEFAULT_EMAIL= 23 | 24 | # Change this to a FQDN as needed 25 | export EXTERNAL_URL="https://localhost:8080" 26 | 27 | export TWILIO_ACCOUNT="your Account SID from twil.io/console" 28 | export TWILIO_TOKEN="your Token from twil.io/console" 29 | 30 | export TWILIO_VERIFY_NAME="calvinx" 31 | export TWILIO_VERIFY="servicetoken" -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/show-example.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Show example 3 | about: Show example in README 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | - With pull requests: 2 | - Open your pull request against `master` 3 | - Your pull request should have no more than two commits, if not you should squash them. 4 | - It should pass all tests in the available continuous integrations systems such as CircleCI. 5 | - You should add/modify tests to cover your proposed code changes. 6 | - If your pull request contains a new feature, please document it on the README. 7 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: gomod 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | ignore: 9 | - dependency-name: github.com/spf13/cobra 10 | versions: 11 | - 1.1.2 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/go 3 | # Edit at https://www.gitignore.io/?templates=go 4 | 5 | ### Go ### 6 | # Binaries for programs and plugins 7 | *.exe 8 | *.exe~ 9 | *.dll 10 | *.so 11 | *.dylib 12 | 13 | # Test binary, built with `go test -c` 14 | *.test 15 | 16 | # Output of the go coverage tool, specifically when used with LiteIDE 17 | *.out 18 | 19 | # Dependency directories (remove the comment below to include it) 20 | # vendor/ 21 | 22 | ### Go Patch ### 23 | /vendor/ 24 | /Godeps/ 25 | 26 | # End of https://www.gitignore.io/api/go 27 | 28 | gin-go-pg 29 | cc-test-reporter 30 | .env* 31 | !.env.sample 32 | bin 33 | # Created by https://www.gitignore.io/api/visualstudiocode 34 | # Edit at https://www.gitignore.io/?templates=visualstudiocode 35 | 36 | ### VisualStudioCode ### 37 | .vscode/* 38 | !.vscode/settings.json 39 | !.vscode/tasks.json 40 | !.vscode/launch.json 41 | !.vscode/extensions.json 42 | 43 | ### VisualStudioCode Patch ### 44 | # Ignore all local history of files 45 | .history 46 | 47 | # End of https://www.gitignore.io/api/visualstudiocode 48 | 49 | # Created by https://www.gitignore.io/api/macos 50 | # Edit at https://www.gitignore.io/?templates=macos 51 | 52 | ### macOS ### 53 | # General 54 | .DS_Store 55 | .AppleDouble 56 | .LSOverride 57 | 58 | # Icon must end with two \r 59 | Icon 60 | 61 | # Thumbnails 62 | ._* 63 | 64 | # Files that might appear in the root of a volume 65 | .DocumentRevisions-V100 66 | .fseventsd 67 | .Spotlight-V100 68 | .TemporaryItems 69 | .Trashes 70 | .VolumeIcon.icns 71 | .com.apple.timemachine.donotpresent 72 | 73 | # Directories potentially created on remote AFP share 74 | .AppleDB 75 | .AppleDesktop 76 | Network Trash Folder 77 | Temporary Items 78 | .apdisk 79 | 80 | # End of https://www.gitignore.io/api/macos 81 | 82 | tmp 83 | tmp2 -------------------------------------------------------------------------------- /.mergify.yml: -------------------------------------------------------------------------------- 1 | pull_request_rules: 2 | - name: automatic merge for Dependabot pull requests 3 | conditions: 4 | - author=dependabot[bot] 5 | - status-success=Circle CI - Pull Request 6 | actions: 7 | merge: 8 | method: merge 9 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Launch", 9 | "type": "go", 10 | "request": "launch", 11 | "mode": "auto", 12 | "program": "${fileDirname}", 13 | "env": {}, 14 | "args": [] 15 | } 16 | ] 17 | } -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at calvin@calvinx.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | When contributing to this repository, please first discuss the change you wish to make via issue, 4 | email, or any other method with the owners of this repository before making a change. 5 | 6 | Please note we have a code of conduct, please follow it in all your interactions with the project. 7 | 8 | ## Pull Request Process 9 | 10 | 1. Ensure any install or build dependencies are removed before the end of the layer when doing a 11 | build. 12 | 2. Update the README.md with details of changes to the interface, this includes new environment 13 | variables, exposed ports, useful file locations and container parameters. 14 | 3. Increase the version numbers in any examples files and the README.md to the new version that this 15 | Pull Request would represent. The versioning scheme we use is [SemVer](http://semver.org/). 16 | 4. You may merge the Pull Request in once you have the sign-off of two other developers, or if you 17 | do not have permission to do that, you may request the second reviewer to merge it for you. 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![CircleCI](https://img.shields.io/circleci/build/github/gogjango/gjango) [![Maintainability](https://api.codeclimate.com/v1/badges/33f9e187fee5dc62a4d7/maintainability)](https://codeclimate.com/github/gogjango/gjango/maintainability) [![Test Coverage](https://api.codeclimate.com/v1/badges/33f9e187fee5dc62a4d7/test_coverage)](https://codeclimate.com/github/gogjango/gjango/test_coverage) [![Go Report Card](https://goreportcard.com/badge/github.com/gogjango/gjango)](https://goreportcard.com/report/github.com/gogjango/gjango) ![GitHub](https://img.shields.io/github/license/calvinchengx/gin-go-pg) 2 | 3 | 4 | # golang gin with go-pg orm 5 | 6 | An example project that uses golang gin as webserver, and go-pg library for connecting with a PostgreSQL database. 7 | 8 | ## Get started 9 | 10 | ```bash 11 | # postgresql config 12 | cp .env.sample .env 13 | source .env 14 | ``` 15 | 16 | ```bash 17 | # get dependencies and run 18 | go get -v ./... 19 | go run . 20 | ``` 21 | 22 | ## Tests and coverage 23 | 24 | ### Run all tests 25 | 26 | ```bash 27 | go test -coverprofile c.out ./... 28 | go tool cover -html=c.out 29 | 30 | # or simply 31 | ./test.sh 32 | ``` 33 | 34 | ### Run only integration tests 35 | 36 | ```bash 37 | go test -v -run Integration ./... 38 | 39 | ./test.sh -i 40 | ``` 41 | 42 | ### Run only unit tests 43 | 44 | ```bash 45 | go test -v -short ./... 46 | 47 | # without coverage 48 | ./test.sh -s 49 | # with coverage 50 | ./test.sh -s -c 51 | ``` 52 | 53 | ## Schema migration and cli management commands 54 | 55 | ```bash 56 | # create a new database based on config values in .env 57 | go run . create_db 58 | 59 | # create our database schema 60 | go run . create_schema 61 | 62 | # create our superadmin user, which is used to administer our API server 63 | go run . create_superadmin 64 | 65 | # schema migration and subcommands are available in the migrate subcommand 66 | # go run . migrate [command] 67 | ``` 68 | -------------------------------------------------------------------------------- /apperr/apperr.go: -------------------------------------------------------------------------------- 1 | package apperr 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/gin-gonic/gin" 8 | "gopkg.in/go-playground/validator.v8" 9 | ) 10 | 11 | // APPError is the default error struct containing detailed information about the error 12 | type APPError struct { 13 | // HTTP Status code to be set in response 14 | Status int `json:"-"` 15 | // Message is the error message that may be displayed to end users 16 | Message string `json:"message,omitempty"` 17 | } 18 | 19 | var ( 20 | // Generic error 21 | Generic = NewStatus(http.StatusInternalServerError) 22 | // DB represents database related errors 23 | DB = NewStatus(http.StatusInternalServerError) 24 | // Forbidden represents access to forbidden resource error 25 | Forbidden = NewStatus(http.StatusForbidden) 26 | // BadRequest represents error for bad requests 27 | BadRequest = NewStatus(http.StatusBadRequest) 28 | // NotFound represents errors for not found resources 29 | NotFound = NewStatus(http.StatusNotFound) 30 | // Unauthorized represents errors for unauthorized requests 31 | Unauthorized = NewStatus(http.StatusUnauthorized) 32 | ) 33 | 34 | // NewStatus generates new error containing only http status code 35 | func NewStatus(status int) *APPError { 36 | return &APPError{Status: status} 37 | } 38 | 39 | // New generates an application error 40 | func New(status int, msg string) *APPError { 41 | return &APPError{Status: status, Message: msg} 42 | } 43 | 44 | // Error returns the error message. 45 | func (e APPError) Error() string { 46 | return e.Message 47 | } 48 | 49 | var validationErrors = map[string]string{ 50 | "required": " is required, but was not received", 51 | "min": "'s value or length is less than allowed", 52 | "max": "'s value or length is bigger than allowed", 53 | } 54 | 55 | func getVldErrorMsg(s string) string { 56 | if v, ok := validationErrors[s]; ok { 57 | return v 58 | } 59 | return " failed on " + s + " validation" 60 | } 61 | 62 | // Response writes an error response to client 63 | func Response(c *gin.Context, err error) { 64 | switch err.(type) { 65 | case *APPError: 66 | e := err.(*APPError) 67 | if e.Message == "" { 68 | c.AbortWithStatus(e.Status) 69 | } else { 70 | c.AbortWithStatusJSON(e.Status, e) 71 | } 72 | return 73 | case validator.ValidationErrors: 74 | var errMsg []string 75 | e := err.(validator.ValidationErrors) 76 | for _, v := range e { 77 | errMsg = append(errMsg, fmt.Sprintf("%s%s", v.Name, getVldErrorMsg(v.ActualTag))) 78 | } 79 | c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"message": errMsg}) 80 | default: 81 | c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{ 82 | "message": err.Error(), 83 | }) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /cmd/create_db.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/gogjango/gjango/config" 7 | "github.com/gogjango/gjango/manager" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | // createCmd represents the migrate command 12 | var createdbCmd = &cobra.Command{ 13 | Use: "create_db", 14 | Short: "create_db creates a database user and database from database parameters declared in config", 15 | Long: `create_db creates a database user and database from database parameters declared in config`, 16 | Run: func(cmd *cobra.Command, args []string) { 17 | fmt.Println("create_db called") 18 | p := config.GetPostgresConfig() 19 | 20 | // connection to db as postgres superuser 21 | dbSuper := config.GetPostgresSuperUserConnection() 22 | defer dbSuper.Close() 23 | 24 | manager.CreateDatabaseUserIfNotExist(dbSuper, p) 25 | manager.CreateDatabaseIfNotExist(dbSuper, p) 26 | }, 27 | } 28 | 29 | func init() { 30 | rootCmd.AddCommand(createdbCmd) 31 | } 32 | -------------------------------------------------------------------------------- /cmd/create_schema.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/gogjango/gjango/config" 7 | "github.com/gogjango/gjango/manager" 8 | "github.com/gogjango/gjango/repository" 9 | "github.com/gogjango/gjango/secret" 10 | "github.com/spf13/cobra" 11 | "go.uber.org/zap" 12 | ) 13 | 14 | // createschemaCmd represents the createschema command 15 | var createSchemaCmd = &cobra.Command{ 16 | Use: "create_schema", 17 | Short: "create_schema creates the initial database schema for the existing database", 18 | Long: `create_schema creates the initial database schema for the existing database`, 19 | Run: func(cmd *cobra.Command, args []string) { 20 | fmt.Println("createschema called") 21 | 22 | db := config.GetConnection() 23 | log, _ := zap.NewDevelopment() 24 | defer log.Sync() 25 | accountRepo := repository.NewAccountRepo(db, log, secret.New()) 26 | roleRepo := repository.NewRoleRepo(db, log) 27 | 28 | m := manager.NewManager(accountRepo, roleRepo, db) 29 | models := manager.GetModels() 30 | m.CreateSchema(models...) 31 | m.CreateRoles() 32 | }, 33 | } 34 | 35 | func init() { 36 | rootCmd.AddCommand(createSchemaCmd) 37 | } 38 | -------------------------------------------------------------------------------- /cmd/create_superadmin.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | 7 | "github.com/gogjango/gjango/config" 8 | "github.com/gogjango/gjango/manager" 9 | "github.com/gogjango/gjango/repository" 10 | "github.com/gogjango/gjango/secret" 11 | "github.com/spf13/cobra" 12 | "go.uber.org/zap" 13 | ) 14 | 15 | var email string 16 | var password string 17 | var createSuperAdminCmd = &cobra.Command{ 18 | Use: "create_superadmin", 19 | Short: "create_superadmin creates a superadmin user that has access to manage all other users in the system", 20 | Long: `create_superadmin creates a superadmin user that has access to manage all other users in the system`, 21 | Run: func(cmd *cobra.Command, args []string) { 22 | fmt.Println("create_superadmin called") 23 | 24 | email, _ = cmd.Flags().GetString("email") 25 | fmt.Println(email) 26 | if !validateEmail(email) { 27 | fmt.Println("Invalid email provided; superadmin user not created") 28 | return 29 | } 30 | 31 | password, _ = cmd.Flags().GetString("password") 32 | fmt.Println(password) 33 | if password == "" { 34 | password, _ = secret.GenerateRandomString(16) 35 | fmt.Printf("No password provided, so we have generated one for you: %s\n", password) 36 | } 37 | 38 | db := config.GetConnection() 39 | log, _ := zap.NewDevelopment() 40 | defer log.Sync() 41 | accountRepo := repository.NewAccountRepo(db, log, secret.New()) 42 | roleRepo := repository.NewRoleRepo(db, log) 43 | 44 | m := manager.NewManager(accountRepo, roleRepo, db) 45 | m.CreateSuperAdmin(email, password) 46 | }, 47 | } 48 | 49 | func init() { 50 | localFlags := createSuperAdminCmd.Flags() 51 | localFlags.StringVarP(&email, "email", "e", "", "SuperAdmin user's email") 52 | localFlags.StringVarP(&password, "password", "p", "", "SuperAdmin user's password") 53 | createSuperAdminCmd.MarkFlagRequired("email") 54 | rootCmd.AddCommand(createSuperAdminCmd) 55 | } 56 | 57 | func validateEmail(email string) bool { 58 | Re := regexp.MustCompile(`^[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,4}$`) 59 | return Re.MatchString(email) 60 | } 61 | -------------------------------------------------------------------------------- /cmd/generate_secret.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | 7 | "github.com/gogjango/gjango/secret" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | // createCmd represents the migrate command 12 | var generateSecretCmd = &cobra.Command{ 13 | Use: "generate_secret", 14 | Short: "generate_secret", 15 | Long: `generate_secret`, 16 | Run: func(cmd *cobra.Command, args []string) { 17 | s, err := secret.GenerateRandomString(256) 18 | if err != nil { 19 | log.Fatal(err) 20 | } 21 | fmt.Printf("\nJWT_SECRET=%s\n\n", s) 22 | }, 23 | } 24 | 25 | func init() { 26 | rootCmd.AddCommand(generateSecretCmd) 27 | } 28 | -------------------------------------------------------------------------------- /cmd/migrate.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | // migrateCmd represents the migrate command 8 | var migrateCmd = &cobra.Command{ 9 | Use: "migrate", 10 | Short: "migrate runs schema migration", 11 | Long: `migrate runs schema migration`, 12 | } 13 | 14 | func init() { 15 | rootCmd.AddCommand(migrateCmd) 16 | 17 | // Here you will define your flags and configuration settings. 18 | 19 | // Cobra supports Persistent Flags which will work for this command 20 | // and all subcommands, e.g.: 21 | // migrateCmd.PersistentFlags().String("foo", "", "A help for foo") 22 | 23 | // Cobra supports local flags which will only run when this command 24 | // is called directly, e.g.: 25 | // migrateCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") 26 | } 27 | -------------------------------------------------------------------------------- /cmd/migrate_down.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | 7 | "github.com/gogjango/gjango/migration" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | // downCmd represents the down command 12 | var downCmd = &cobra.Command{ 13 | Use: "down", 14 | Short: "reverts the last migration", 15 | Long: `reverts the last migration`, 16 | Run: func(cmd *cobra.Command, args []string) { 17 | fmt.Println("down called") 18 | err := migration.Run("down") 19 | if err != nil { 20 | log.Fatal(err) 21 | } 22 | }, 23 | } 24 | 25 | func init() { 26 | migrateCmd.AddCommand(downCmd) 27 | } 28 | -------------------------------------------------------------------------------- /cmd/migrate_init.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | 7 | "github.com/gogjango/gjango/migration" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | // initCmd represents the init command 12 | var initCmd = &cobra.Command{ 13 | Use: "init", 14 | Short: "init creates version info table in the database", 15 | Long: `init creates version info table in the database`, 16 | Run: func(cmd *cobra.Command, args []string) { 17 | fmt.Println("init called") 18 | err := migration.Run("init") 19 | if err != nil { 20 | log.Fatal(err) 21 | } 22 | }, 23 | } 24 | 25 | func init() { 26 | migrateCmd.AddCommand(initCmd) 27 | } 28 | -------------------------------------------------------------------------------- /cmd/migrate_reset.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | 7 | "github.com/gogjango/gjango/migration" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | // resetCmd represents the reset command 12 | var resetCmd = &cobra.Command{ 13 | Use: "reset", 14 | Short: "reset all migrations", 15 | Long: `reset all migrations`, 16 | Run: func(cmd *cobra.Command, args []string) { 17 | fmt.Println("reset called") 18 | err := migration.Run("reset") 19 | if err != nil { 20 | log.Fatal(err) 21 | } 22 | }, 23 | } 24 | 25 | func init() { 26 | migrateCmd.AddCommand(resetCmd) 27 | } 28 | -------------------------------------------------------------------------------- /cmd/migrate_set_version.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "log" 5 | "strconv" 6 | 7 | "github.com/gogjango/gjango/migration" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | // setVersionCmd represents the version command 12 | var setVersionCmd = &cobra.Command{ 13 | Use: "set_version [version]", 14 | Short: "sets db version without running migrations", 15 | Long: `sets db version without running migrations`, 16 | Run: func(cmd *cobra.Command, args []string) { 17 | var passthrough = []string{"set_version"} 18 | if len(args) > 0 { 19 | _, err := strconv.Atoi(args[0]) 20 | if err != nil { 21 | passthrough = append(passthrough, args[0]) 22 | } 23 | } 24 | 25 | err := migration.Run(passthrough...) 26 | if err != nil { 27 | log.Fatal(err) 28 | } 29 | }, 30 | } 31 | 32 | func init() { 33 | migrateCmd.AddCommand(setVersionCmd) 34 | } 35 | -------------------------------------------------------------------------------- /cmd/migrate_up.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "log" 5 | "strconv" 6 | 7 | "github.com/gogjango/gjango/migration" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | // upCmd represents the up command 12 | var upCmd = &cobra.Command{ 13 | Use: "up [target]", 14 | Short: "runs all available migrations or up to the target if provided", 15 | Long: `runs all available migrations or up to the target if provided`, 16 | Run: func(cmd *cobra.Command, args []string) { 17 | var passthrough = []string{"up"} 18 | if len(args) > 0 { 19 | _, err := strconv.Atoi(args[0]) 20 | if err != nil { 21 | passthrough = append(passthrough, args[0]) 22 | } 23 | } 24 | 25 | err := migration.Run(passthrough...) 26 | if err != nil { 27 | log.Fatal(err) 28 | } 29 | }, 30 | } 31 | 32 | func init() { 33 | migrateCmd.AddCommand(upCmd) 34 | } 35 | -------------------------------------------------------------------------------- /cmd/migrate_version.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | 7 | "github.com/gogjango/gjango/migration" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | // versionCmd represents the version command 12 | var versionCmd = &cobra.Command{ 13 | Use: "version", 14 | Short: "version prints current db version", 15 | Long: `version prints current db version`, 16 | Run: func(cmd *cobra.Command, args []string) { 17 | fmt.Println("version called") 18 | err := migration.Run("version") 19 | if err != nil { 20 | log.Fatal(err) 21 | } 22 | }, 23 | } 24 | 25 | func init() { 26 | migrateCmd.AddCommand(versionCmd) 27 | } 28 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | 8 | "github.com/gogjango/gjango/route" 9 | "github.com/gogjango/gjango/server" 10 | "github.com/spf13/cobra" 11 | 12 | homedir "github.com/mitchellh/go-homedir" 13 | "github.com/spf13/viper" 14 | ) 15 | 16 | // routes will be attached to s 17 | var s server.Server 18 | 19 | var cfgFile string 20 | 21 | // rootCmd represents the base command when called without any subcommands 22 | var rootCmd = &cobra.Command{ 23 | Use: "gjango", 24 | Short: "A simple golang framework for API server", 25 | Long: `gjango is a simple golang framework for building high performance, easy-to-extend API web servers. 26 | Inspired by django python web framework, gjango aims to make it simple and fast to build production grade, web applications. 27 | 28 | By default, our program will run the API server.`, 29 | // Uncomment the following line if your bare application 30 | // has an action associated with it: 31 | Run: func(cmd *cobra.Command, args []string) { 32 | var env string 33 | var ok bool 34 | if env, ok = os.LookupEnv("GJANGO_ENV"); !ok { 35 | env = "dev" 36 | fmt.Printf("Run server in %s mode\n", env) 37 | } 38 | err := s.Run(env) 39 | if err != nil { 40 | log.Fatal(err) 41 | } 42 | }, 43 | } 44 | 45 | // Execute adds all child commands to the root command and sets flags appropriately. 46 | // This is called by main.main(). It only needs to happen once to the rootCmd. 47 | func Execute(customRouteServices []route.ServicesI) { 48 | s.RouteServices = customRouteServices 49 | if err := rootCmd.Execute(); err != nil { 50 | fmt.Println(err) 51 | os.Exit(1) 52 | } 53 | } 54 | 55 | func init() { 56 | cobra.OnInitialize(initConfig) 57 | 58 | // Here you will define your flags and configuration settings. 59 | // Cobra supports persistent flags, which, if defined here, 60 | // will be global for your application. 61 | 62 | rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.gjango.yaml)") 63 | 64 | // Cobra also supports local flags, which will only run 65 | // when this action is called directly. 66 | // rootCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") 67 | } 68 | 69 | // initConfig reads in config file and ENV variables if set. 70 | func initConfig() { 71 | if cfgFile != "" { 72 | // Use config file from the flag. 73 | viper.SetConfigFile(cfgFile) 74 | } else { 75 | // Find home directory. 76 | home, err := homedir.Dir() 77 | if err != nil { 78 | fmt.Println(err) 79 | os.Exit(1) 80 | } 81 | 82 | // Search config in home directory with name ".gjango" (without extension). 83 | viper.AddConfigPath(home) 84 | viper.SetConfigName(".gjango") 85 | } 86 | 87 | viper.AutomaticEnv() // read in environment variables that match 88 | 89 | // If a config file is found, read it in. 90 | if err := viper.ReadInConfig(); err == nil { 91 | fmt.Println("Using config file:", viper.ConfigFileUsed()) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "log" 5 | "path/filepath" 6 | "runtime" 7 | 8 | "github.com/gin-gonic/gin" 9 | "github.com/spf13/viper" 10 | ) 11 | 12 | // Load returns Configuration struct 13 | func Load(env string) *Configuration { 14 | _, filePath, _, _ := runtime.Caller(0) 15 | configName := "config." + env + ".yaml" 16 | configPath := filePath[:len(filePath)-9] + "files" + string(filepath.Separator) 17 | 18 | viper.SetConfigName(configName) 19 | viper.AddConfigPath(configPath) 20 | viper.SetConfigType("yaml") 21 | 22 | err := viper.ReadInConfig() 23 | if err != nil { 24 | log.Fatal(err) 25 | } 26 | 27 | var config Configuration 28 | viper.Unmarshal(&config) 29 | setGinMode(config.Server.Mode) 30 | 31 | return &config 32 | } 33 | 34 | // Configuration holds data necessery for configuring application 35 | type Configuration struct { 36 | Server *Server `yaml:"server"` 37 | } 38 | 39 | // Server holds data necessary for server configuration 40 | type Server struct { 41 | Mode string `yaml:"mode"` 42 | } 43 | 44 | func setGinMode(mode string) { 45 | switch mode { 46 | case "release": 47 | gin.SetMode(gin.ReleaseMode) 48 | break 49 | case "test": 50 | gin.SetMode(gin.TestMode) 51 | break 52 | default: 53 | gin.SetMode(gin.DebugMode) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /config/files/config.dev.yaml: -------------------------------------------------------------------------------- 1 | server: 2 | mode: "debug" -------------------------------------------------------------------------------- /config/files/config.prod.yaml: -------------------------------------------------------------------------------- 1 | server: 2 | mode: "release" -------------------------------------------------------------------------------- /config/files/config.staging.yaml: -------------------------------------------------------------------------------- 1 | server: 2 | mode: "release" -------------------------------------------------------------------------------- /config/files/config.test-e2e.yaml: -------------------------------------------------------------------------------- 1 | server: 2 | mode: "test" -------------------------------------------------------------------------------- /config/files/config.test-int.yaml: -------------------------------------------------------------------------------- 1 | server: 2 | mode: test -------------------------------------------------------------------------------- /config/files/config.test.yaml: -------------------------------------------------------------------------------- 1 | server: 2 | mode: "test" -------------------------------------------------------------------------------- /config/jwt.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "log" 7 | "path" 8 | "path/filepath" 9 | "runtime" 10 | "strings" 11 | 12 | "github.com/gogjango/gjango/secret" 13 | "github.com/joho/godotenv" 14 | "github.com/mcuadros/go-defaults" 15 | "github.com/spf13/viper" 16 | ) 17 | 18 | // LoadJWT returns our JWT with env variables and relevant defaults 19 | func LoadJWT(env string) *JWT { 20 | jwt := new(JWT) 21 | defaults.SetDefaults(jwt) 22 | 23 | _, b, _, _ := runtime.Caller(0) 24 | d := path.Join(path.Dir(b)) 25 | projectRoot := filepath.Dir(d) 26 | suffix := "" 27 | if env != "" { 28 | suffix = suffix + "." + env 29 | } 30 | dotenvPath := path.Join(projectRoot, ".env"+suffix) 31 | _ = godotenv.Load(dotenvPath) 32 | 33 | viper.AutomaticEnv() 34 | 35 | jwt.Secret = viper.GetString("JWT_SECRET") 36 | if jwt.Secret == "" { 37 | if strings.HasPrefix(env, "test") { 38 | // generate jwt secret and write into file 39 | s, err := secret.GenerateRandomString(256) 40 | if err != nil { 41 | log.Fatal(err) 42 | } 43 | jwtString := fmt.Sprintf("JWT_SECRET=%s\n", s) 44 | err = ioutil.WriteFile(dotenvPath, []byte(jwtString), 0644) 45 | if err != nil { 46 | log.Fatal(err) 47 | } 48 | } else { 49 | log.Fatalf("Failed to set your environment variable JWT_SECRET. \n" + 50 | "Please do so via \n" + 51 | "go run . generate_secret\n" + 52 | "export JWT_SECRET=[the generated secret]") 53 | } 54 | } 55 | 56 | return jwt 57 | } 58 | 59 | // JWT holds data necessary for JWT configuration 60 | type JWT struct { 61 | Realm string `default:"jwtrealm"` 62 | Secret string `default:""` 63 | Duration int `default:"15"` 64 | RefreshDuration int `default:"10"` 65 | MaxRefresh int `default:"10"` 66 | SigningAlgorithm string `default:"HS256"` 67 | } 68 | -------------------------------------------------------------------------------- /config/mail.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/caarlos0/env/v6" 7 | ) 8 | 9 | // MailConfig persists the config for our PostgreSQL database connection 10 | type MailConfig struct { 11 | Name string `env:"DEFAULT_NAME"` 12 | Email string `env:"DEFAULT_EMAIL"` 13 | } 14 | 15 | // GetMailConfig returns a MailConfig pointer with the correct Mail Config values 16 | func GetMailConfig() *MailConfig { 17 | c := MailConfig{} 18 | if err := env.Parse(&c); err != nil { 19 | fmt.Printf("%+v\n", err) 20 | } 21 | return &c 22 | } 23 | -------------------------------------------------------------------------------- /config/postgres.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/url" 7 | "strings" 8 | 9 | "github.com/caarlos0/env/v6" 10 | "github.com/go-pg/pg/v9" 11 | ) 12 | 13 | // PostgresConfig persists the config for our PostgreSQL database connection 14 | type PostgresConfig struct { 15 | URL string `env:"DATABASE_URL"` // DATABASE_URL will be used in preference if it exists 16 | Host string `env:"POSTGRES_HOST" envDefault:"localhost"` 17 | Port string `env:"POSTGRES_PORT" envDefault:"5432"` 18 | User string `env:"POSTGRES_USER"` 19 | Password string `env:"POSTGRES_PASSWORD"` 20 | Database string `env:"POSTGRES_DB"` 21 | } 22 | 23 | // PostgresSuperUser persists the config for our PostgreSQL superuser 24 | type PostgresSuperUser struct { 25 | Host string `env:"POSTGRES_HOST" envDefault:"localhost"` 26 | Port string `env:"POSTGRES_PORT" envDefault:"5432"` 27 | User string `env:"POSTGRES_SUPERUSER" envDefault:"postgres"` 28 | Password string `env:"POSTGRES_SUPERUSER_PASSWORD" envDefault:""` 29 | Database string `env:"POSTGRES_SUPERUSER_DB" envDefault:"postgres"` 30 | } 31 | 32 | // GetConnection returns our pg database connection 33 | // usage: 34 | // db := config.GetConnection() 35 | // defer db.Close() 36 | func GetConnection() *pg.DB { 37 | c := GetPostgresConfig() 38 | // if DATABASE_URL is valid, we will use its constituent values in preference 39 | validConfig, err := validPostgresURL(c.URL) 40 | if err == nil { 41 | c = validConfig 42 | } 43 | db := pg.Connect(&pg.Options{ 44 | Addr: c.Host + ":" + c.Port, 45 | User: c.User, 46 | Password: c.Password, 47 | Database: c.Database, 48 | PoolSize: 150, 49 | }) 50 | return db 51 | } 52 | 53 | // GetPostgresConfig returns a PostgresConfig pointer with the correct Postgres Config values 54 | func GetPostgresConfig() *PostgresConfig { 55 | c := PostgresConfig{} 56 | if err := env.Parse(&c); err != nil { 57 | fmt.Printf("%+v\n", err) 58 | } 59 | return &c 60 | } 61 | 62 | // GetPostgresSuperUserConnection gets the corresponding db connection for our superuser 63 | func GetPostgresSuperUserConnection() *pg.DB { 64 | c := getPostgresSuperUser() 65 | db := pg.Connect(&pg.Options{ 66 | Addr: c.Host + ":" + c.Port, 67 | User: c.User, 68 | Password: c.Password, 69 | Database: c.Database, 70 | PoolSize: 150, 71 | }) 72 | return db 73 | } 74 | 75 | func getPostgresSuperUser() *PostgresSuperUser { 76 | c := PostgresSuperUser{} 77 | if err := env.Parse(&c); err != nil { 78 | fmt.Printf("%+v\n", err) 79 | } 80 | return &c 81 | } 82 | 83 | func validPostgresURL(URL string) (*PostgresConfig, error) { 84 | if URL == "" || strings.TrimSpace(URL) == "" { 85 | return nil, errors.New("database url is blank") 86 | } 87 | 88 | validURL, err := url.Parse(URL) 89 | if err != nil { 90 | return nil, err 91 | } 92 | c := &PostgresConfig{} 93 | c.URL = URL 94 | c.Host = validURL.Host 95 | c.Database = validURL.Path 96 | c.Port = validURL.Port() 97 | c.User = validURL.User.Username() 98 | c.Password, _ = validURL.User.Password() 99 | return c, nil 100 | } 101 | -------------------------------------------------------------------------------- /config/site.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/caarlos0/env/v6" 7 | ) 8 | 9 | // SiteConfig persists global configs needed for our application 10 | type SiteConfig struct { 11 | ExternalURL string `env:"EXTERNAL_URL" envDefault:"http://localhost:8080"` 12 | } 13 | 14 | // GetSiteConfig returns a SiteConfig pointer with the correct Site Config values 15 | func GetSiteConfig() *SiteConfig { 16 | c := SiteConfig{} 17 | if err := env.Parse(&c); err != nil { 18 | fmt.Printf("%+v\n", err) 19 | } 20 | return &c 21 | } 22 | -------------------------------------------------------------------------------- /config/twilio.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/caarlos0/env/v6" 7 | ) 8 | 9 | // TwilioConfig persists the config for our Twilio services 10 | type TwilioConfig struct { 11 | Account string `env:"TWILIO_ACCOUNT"` 12 | Token string `env:"TWILIO_TOKEN"` 13 | VerifyName string `env:"TWILIO_VERIFY_NAME"` 14 | Verify string `env:"TWILIO_VERIFY"` 15 | } 16 | 17 | // GetTwilioConfig returns a TwilioConfig pointer with the correct Mail Config values 18 | func GetTwilioConfig() *TwilioConfig { 19 | c := TwilioConfig{} 20 | if err := env.Parse(&c); err != nil { 21 | fmt.Printf("%+v\n", err) 22 | } 23 | return &c 24 | } 25 | -------------------------------------------------------------------------------- /e2e/e2e.go: -------------------------------------------------------------------------------- 1 | package e2e 2 | 3 | import ( 4 | "github.com/gogjango/gjango/manager" 5 | "github.com/gogjango/gjango/model" 6 | ) 7 | 8 | // SetupDatabase creates the schema, populates it with data and returns with superadmin user 9 | func SetupDatabase(m *manager.Manager) (*model.User, error) { 10 | models := manager.GetModels() 11 | m.CreateSchema(models...) 12 | m.CreateRoles() 13 | return m.CreateSuperAdmin("superuser@example.org", "testpassword") 14 | } 15 | -------------------------------------------------------------------------------- /e2e/e2e_i_test.go: -------------------------------------------------------------------------------- 1 | package e2e_test 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path" 7 | "path/filepath" 8 | "runtime" 9 | "testing" 10 | 11 | embeddedpostgres "github.com/fergusstrange/embedded-postgres" 12 | "github.com/gin-contrib/cors" 13 | "github.com/gin-gonic/gin" 14 | "github.com/go-pg/pg/v9" 15 | "github.com/gogjango/gjango/config" 16 | "github.com/gogjango/gjango/e2e" 17 | "github.com/gogjango/gjango/manager" 18 | mw "github.com/gogjango/gjango/middleware" 19 | "github.com/gogjango/gjango/mock" 20 | "github.com/gogjango/gjango/model" 21 | "github.com/gogjango/gjango/repository" 22 | "github.com/gogjango/gjango/route" 23 | "github.com/gogjango/gjango/secret" 24 | "github.com/gogjango/gjango/testhelper" 25 | "github.com/stretchr/testify/assert" 26 | "github.com/stretchr/testify/suite" 27 | "go.uber.org/zap" 28 | ) 29 | 30 | var ( 31 | superUser *model.User 32 | isCI bool 33 | port uint32 = 5432 // uses 5432 in CI, and 9877 when running integration tests locally, against embedded postgresql 34 | ) 35 | 36 | // end-to-end test constants 37 | const ( 38 | username string = "db_test_user" 39 | password string = "db_test_password" 40 | database string = "db_test_database" 41 | host string = "localhost" 42 | tmpDirname string = "tmp2" 43 | ) 44 | 45 | type E2ETestSuite struct { 46 | suite.Suite 47 | db *pg.DB 48 | postgres *embeddedpostgres.EmbeddedPostgres 49 | m *manager.Manager 50 | r *gin.Engine 51 | v *model.Verification 52 | authToken model.AuthToken 53 | } 54 | 55 | // SetupSuite runs before all tests in this test suite 56 | func (suite *E2ETestSuite) SetupSuite() { 57 | _, b, _, _ := runtime.Caller(0) 58 | d := path.Join(path.Dir(b)) 59 | projectRoot := filepath.Dir(d) 60 | tmpDir := path.Join(projectRoot, tmpDirname) 61 | os.RemoveAll(tmpDir) // ensure that we start afresh 62 | 63 | _, isCI = os.LookupEnv("CIRCLECI") 64 | if !isCI { // not in CI environment, so setup our embedded postgresql for integration test 65 | port = testhelper.AllocatePort(host, 9877) 66 | testConfig := embeddedpostgres.DefaultConfig(). 67 | Username(username). 68 | Password(password). 69 | Database(database). 70 | Version(embeddedpostgres.V12). 71 | RuntimePath(tmpDir). 72 | Port(port) 73 | suite.postgres = embeddedpostgres.NewDatabase(testConfig) 74 | err := suite.postgres.Start() 75 | if err != nil { 76 | fmt.Println(err) 77 | } 78 | } 79 | 80 | suite.db = pg.Connect(&pg.Options{ 81 | Addr: host + ":" + fmt.Sprint(port), 82 | User: username, 83 | Password: password, 84 | Database: database, 85 | }) 86 | 87 | log, _ := zap.NewDevelopment() 88 | defer log.Sync() 89 | accountRepo := repository.NewAccountRepo(suite.db, log, secret.New()) 90 | roleRepo := repository.NewRoleRepo(suite.db, log) 91 | suite.m = manager.NewManager(accountRepo, roleRepo, suite.db) 92 | 93 | superUser, _ = e2e.SetupDatabase(suite.m) 94 | 95 | gin.SetMode(gin.TestMode) 96 | r := gin.Default() 97 | 98 | // middleware 99 | mw.Add(r, cors.Default()) 100 | 101 | // load configuration 102 | _ = config.Load("test") 103 | j := config.LoadJWT("test") 104 | jwt := mw.NewJWT(j) 105 | 106 | // mock mail 107 | m := &mock.Mail{ 108 | SendVerificationEmailFn: suite.sendVerification, 109 | } 110 | // mock mobile 111 | mobile := &mock.Mobile{ 112 | GenerateSMSTokenFn: func(string, string) error { 113 | return nil 114 | }, 115 | CheckCodeFn: func(string, string, string) error { 116 | return nil 117 | }, 118 | } 119 | 120 | // setup routes 121 | rs := route.NewServices(suite.db, log, jwt, m, mobile, r) 122 | rs.SetupV1Routes() 123 | 124 | // we can now test our routes in an end-to-end fashion by making http calls 125 | suite.r = r 126 | } 127 | 128 | // TearDownSuite runs after all tests in this test suite 129 | func (suite *E2ETestSuite) TearDownSuite() { 130 | if !isCI { // not in CI environment, so stop our embedded postgresql db 131 | suite.postgres.Stop() 132 | } 133 | } 134 | 135 | func (suite *E2ETestSuite) TestGetModels() { 136 | models := manager.GetModels() 137 | sql := `SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public';` 138 | var count int 139 | res, err := suite.db.Query(pg.Scan(&count), sql, nil) 140 | 141 | assert.NotNil(suite.T(), res) 142 | assert.Nil(suite.T(), err) 143 | assert.Equal(suite.T(), len(models), count) 144 | 145 | sql = `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public';` 146 | var names pg.Strings 147 | res, err = suite.db.Query(&names, sql, nil) 148 | 149 | assert.NotNil(suite.T(), res) 150 | assert.Nil(suite.T(), err) 151 | assert.Equal(suite.T(), len(models), len(names)) 152 | } 153 | 154 | func (suite *E2ETestSuite) TestSuperUser() { 155 | assert.NotNil(suite.T(), superUser) 156 | } 157 | 158 | func TestE2ETestSuiteIntegration(t *testing.T) { 159 | if testing.Short() { 160 | t.Skip("skipping integration test") 161 | return 162 | } 163 | suite.Run(t, new(E2ETestSuite)) 164 | } 165 | 166 | // our mock verification token is saved into suite.token for subsequent use 167 | func (suite *E2ETestSuite) sendVerification(email string, v *model.Verification) error { 168 | suite.v = v 169 | return nil 170 | } 171 | -------------------------------------------------------------------------------- /e2e/login_i_test.go: -------------------------------------------------------------------------------- 1 | package e2e_test 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "io/ioutil" 7 | "log" 8 | "net/http" 9 | "net/http/httptest" 10 | "time" 11 | 12 | "github.com/gogjango/gjango/model" 13 | "github.com/gogjango/gjango/request" 14 | "github.com/stretchr/testify/assert" 15 | ) 16 | 17 | func (suite *E2ETestSuite) TestLogin() { 18 | t := suite.T() 19 | 20 | ts := httptest.NewServer(suite.r) 21 | defer ts.Close() 22 | 23 | url := ts.URL + "/login" 24 | 25 | req := &request.Credentials{ 26 | Email: "superuser@example.org", 27 | Password: "testpassword", 28 | } 29 | b, err := json.Marshal(req) 30 | assert.Nil(t, err) 31 | 32 | resp, err := http.Post(url, "application/json", bytes.NewBuffer(b)) 33 | if err != nil { 34 | log.Fatal(err) 35 | } 36 | defer resp.Body.Close() 37 | 38 | body, err := ioutil.ReadAll(resp.Body) 39 | if err != nil { 40 | log.Fatal(err) 41 | } 42 | 43 | var authToken model.AuthToken 44 | err = json.Unmarshal(body, &authToken) 45 | assert.Nil(t, err) 46 | assert.NotNil(t, authToken) 47 | suite.authToken = authToken 48 | } 49 | 50 | func (suite *E2ETestSuite) TestRefreshToken() { 51 | t := suite.T() 52 | assert.NotNil(t, suite.authToken) 53 | 54 | ts := httptest.NewServer(suite.r) 55 | defer ts.Close() 56 | 57 | // delay by 1 second so that our re-generated JWT will have a 1 second difference 58 | time.Sleep(1 * time.Second) 59 | 60 | url := ts.URL + "/refresh/" + suite.authToken.RefreshToken 61 | resp, err := http.Get(url) 62 | if err != nil { 63 | log.Fatal(err) 64 | } 65 | defer resp.Body.Close() 66 | 67 | assert.Equal(t, http.StatusOK, resp.StatusCode) 68 | assert.Nil(t, err) 69 | 70 | body, err := ioutil.ReadAll(resp.Body) 71 | if err != nil { 72 | log.Fatal(err) 73 | } 74 | 75 | var refreshToken model.RefreshToken 76 | err = json.Unmarshal(body, &refreshToken) 77 | assert.Nil(t, err) 78 | assert.NotNil(t, refreshToken) 79 | 80 | // because of a 1 second delay, our re-generated JWT will definitely be different 81 | assert.NotEqual(t, suite.authToken.Token, refreshToken.Token) 82 | assert.NotEqual(t, suite.authToken.Expires, refreshToken.Expires) 83 | } 84 | -------------------------------------------------------------------------------- /e2e/mobile_i_test.go: -------------------------------------------------------------------------------- 1 | package e2e_test 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "log" 8 | "net/http" 9 | "net/http/httptest" 10 | 11 | "github.com/gogjango/gjango/request" 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | func (suite *E2ETestSuite) TestSignupMobile() { 16 | t := suite.T() 17 | ts := httptest.NewServer(suite.r) 18 | defer ts.Close() 19 | 20 | urlSignupMobile := ts.URL + "/mobile" 21 | 22 | req := &request.MobileSignup{ 23 | CountryCode: "+65", 24 | Mobile: "91919191", 25 | } 26 | b, err := json.Marshal(req) 27 | if err != nil { 28 | log.Fatal(err) 29 | } 30 | resp, err := http.Post(urlSignupMobile, "application/json", bytes.NewBuffer(b)) 31 | if err != nil { 32 | log.Fatal(err) 33 | } 34 | defer resp.Body.Close() 35 | 36 | assert.Equal(t, http.StatusCreated, resp.StatusCode) 37 | assert.Nil(t, err) 38 | 39 | // the sms code will be separately sms-ed to user's mobile phone, trigger above 40 | // we now test against the /mobile/verify 41 | 42 | url := ts.URL + "/mobile/verify" 43 | req2 := &request.MobileVerify{ 44 | CountryCode: "+65", 45 | Mobile: "91919191", 46 | Code: "123456", 47 | Signup: true, 48 | } 49 | b, err = json.Marshal(req2) 50 | if err != nil { 51 | log.Fatal(err) 52 | } 53 | resp, err = http.Post(url, "application/json", bytes.NewBuffer(b)) 54 | if err != nil { 55 | log.Fatal(err) 56 | } 57 | defer resp.Body.Close() 58 | 59 | fmt.Println("Verify Code") 60 | assert.Equal(t, http.StatusOK, resp.StatusCode) 61 | assert.Nil(t, err) 62 | } 63 | -------------------------------------------------------------------------------- /e2e/signupemail_i_test.go: -------------------------------------------------------------------------------- 1 | package e2e_test 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io/ioutil" 8 | "log" 9 | "net/http" 10 | "net/http/httptest" 11 | 12 | "github.com/gogjango/gjango/request" 13 | "github.com/stretchr/testify/assert" 14 | ) 15 | 16 | func (suite *E2ETestSuite) TestSignupEmail() { 17 | 18 | t := suite.T() 19 | 20 | ts := httptest.NewServer(suite.r) 21 | defer ts.Close() 22 | 23 | urlSignup := ts.URL + "/signup" 24 | 25 | req := &request.EmailSignup{ 26 | Email: "user@example.org", 27 | Password: "userpassword1", 28 | PasswordConfirm: "userpassword1", 29 | } 30 | b, err := json.Marshal(req) 31 | if err != nil { 32 | log.Fatal(err) 33 | } 34 | 35 | resp, err := http.Post(urlSignup, "application/json", bytes.NewBuffer(b)) 36 | if err != nil { 37 | log.Fatal(err) 38 | } 39 | defer resp.Body.Close() 40 | 41 | assert.Equal(t, http.StatusCreated, resp.StatusCode) 42 | 43 | body, err := ioutil.ReadAll(resp.Body) 44 | if err != nil { 45 | log.Fatal(err) 46 | } 47 | fmt.Println(string(body)) 48 | 49 | assert.Nil(t, err) 50 | } 51 | 52 | func (suite *E2ETestSuite) TestVerification() { 53 | t := suite.T() 54 | v := suite.v 55 | // verify that we can retrieve our test verification token 56 | assert.NotNil(t, v) 57 | 58 | ts := httptest.NewServer(suite.r) 59 | defer ts.Close() 60 | 61 | url := ts.URL + "/verification/" + v.Token 62 | fmt.Println("This is our verification url", url) 63 | 64 | resp, err := http.Get(url) 65 | if err != nil { 66 | log.Fatal(err) 67 | } 68 | defer resp.Body.Close() 69 | assert.Equal(t, http.StatusOK, resp.StatusCode) 70 | 71 | body, err := ioutil.ReadAll(resp.Body) 72 | if err != nil { 73 | log.Fatal(err) 74 | } 75 | fmt.Println(string(body)) 76 | assert.Nil(t, err) 77 | 78 | // The second time we call our verification url, it should return not found 79 | resp, err = http.Get(url) 80 | assert.Equal(t, http.StatusNotFound, resp.StatusCode) 81 | assert.Nil(t, err) 82 | } 83 | -------------------------------------------------------------------------------- /example/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/gogjango/gjango" 7 | ) 8 | 9 | func main() { 10 | gjango.New(). 11 | WithRoutes(&MyServices{}). 12 | Run() 13 | } 14 | 15 | // MyServices implements github.com/gogjango/gjango/route.ServicesI 16 | type MyServices struct{} 17 | 18 | // SetupRoutes is our implementation of custom routes 19 | func (s *MyServices) SetupRoutes() { 20 | fmt.Println("set up our custom routes!") 21 | } 22 | -------------------------------------------------------------------------------- /gjango.go: -------------------------------------------------------------------------------- 1 | package gjango 2 | 3 | import ( 4 | "github.com/gogjango/gjango/cmd" 5 | "github.com/gogjango/gjango/route" 6 | ) 7 | 8 | // New creates a new Gjango instance 9 | func New() *Gjango { 10 | return &Gjango{} 11 | } 12 | 13 | // Gjango allows us to specify customizations, such as custom route services 14 | type Gjango struct { 15 | RouteServices []route.ServicesI 16 | } 17 | 18 | // WithRoutes is the builder method for us to add in custom route services 19 | func (g *Gjango) WithRoutes(RouteServices ...route.ServicesI) *Gjango { 20 | return &Gjango{RouteServices} 21 | } 22 | 23 | // Run executes our gjango functions or servers 24 | func (g *Gjango) Run() { 25 | cmd.Execute(g.RouteServices) 26 | } 27 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/gogjango/gjango 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/caarlos0/env/v6 v6.7.0 7 | github.com/dgrijalva/jwt-go v3.2.0+incompatible 8 | github.com/fergusstrange/embedded-postgres v1.9.0 9 | github.com/gertd/go-pluralize v0.1.7 10 | github.com/gin-contrib/cors v1.3.1 11 | github.com/gin-gonic/gin v1.7.4 12 | github.com/go-pg/migrations/v7 v7.1.11 13 | github.com/go-pg/pg/v9 v9.2.1 14 | github.com/joho/godotenv v1.4.0 15 | github.com/mcuadros/go-defaults v1.2.0 16 | github.com/mitchellh/go-homedir v1.1.0 17 | github.com/rs/xid v1.3.0 18 | github.com/satori/go.uuid v1.2.0 19 | github.com/sendgrid/rest v2.4.1+incompatible // indirect 20 | github.com/sendgrid/sendgrid-go v3.10.2+incompatible 21 | github.com/spf13/cobra v1.1.3 22 | github.com/spf13/viper v1.9.0 23 | github.com/stretchr/testify v1.7.0 24 | go.uber.org/zap v1.19.0 25 | golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 26 | gopkg.in/go-playground/validator.v8 v8.18.2 27 | ) 28 | -------------------------------------------------------------------------------- /mail/mail.go: -------------------------------------------------------------------------------- 1 | package mail 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/gogjango/gjango/config" 7 | "github.com/gogjango/gjango/model" 8 | "github.com/sendgrid/sendgrid-go" 9 | s "github.com/sendgrid/sendgrid-go/helpers/mail" 10 | ) 11 | 12 | // NewMail generates new Mail variable 13 | func NewMail(mc *config.MailConfig, sc *config.SiteConfig) *Mail { 14 | return &Mail{ 15 | ExternalURL: sc.ExternalURL, 16 | FromName: mc.Name, 17 | FromEmail: mc.Email, 18 | } 19 | } 20 | 21 | // Mail provides a mail service implementation 22 | type Mail struct { 23 | ExternalURL string 24 | FromName string 25 | FromEmail string 26 | } 27 | 28 | // Send email with sendgrid 29 | func (m *Mail) Send(subject string, toName string, toEmail string, content string) error { 30 | from := s.NewEmail(m.FromName, m.FromEmail) 31 | to := s.NewEmail(toName, toEmail) 32 | plainTextContent := content 33 | message := s.NewSingleEmail(from, subject, to, plainTextContent, content) 34 | client := sendgrid.NewSendClient(os.Getenv("SENDGRID_API_KEY")) 35 | _, err := client.Send(message) 36 | if err != nil { 37 | return err 38 | } 39 | return nil 40 | } 41 | 42 | // SendWithDefaults assumes some defaults for sending out email with sendgrid 43 | func (m *Mail) SendWithDefaults(subject, toEmail, content string) error { 44 | err := m.Send(subject, toEmail, toEmail, content) 45 | if err != nil { 46 | return err 47 | } 48 | return nil 49 | } 50 | 51 | // SendVerificationEmail assumes defaults and generates a verification email 52 | func (m *Mail) SendVerificationEmail(toEmail string, v *model.Verification) error { 53 | url := m.ExternalURL + "/verification/" + v.Token 54 | content := "Click on this to verify your account " + url 55 | err := m.SendWithDefaults("Verification Email", toEmail, content) 56 | if err != nil { 57 | return err 58 | } 59 | return nil 60 | } 61 | -------------------------------------------------------------------------------- /mail/mail_interface.go: -------------------------------------------------------------------------------- 1 | package mail 2 | 3 | import "github.com/gogjango/gjango/model" 4 | 5 | // Service is the interface to access our Mail 6 | type Service interface { 7 | Send(subject string, toName string, toEmail string, content string) error 8 | SendWithDefaults(subject, toEmail, content string) error 9 | SendVerificationEmail(toEmail string, v *model.Verification) error 10 | } 11 | -------------------------------------------------------------------------------- /manager/createdb.go: -------------------------------------------------------------------------------- 1 | package manager 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/go-pg/pg/v9" 7 | "github.com/gogjango/gjango/config" 8 | ) 9 | 10 | // CreateDatabaseIfNotExist creates our postgresql database from postgres config 11 | func CreateDatabaseIfNotExist(db *pg.DB, p *config.PostgresConfig) { 12 | statement := fmt.Sprintf(`SELECT 1 AS result FROM pg_database WHERE datname = '%s';`, p.Database) 13 | res, _ := db.Exec(statement) 14 | if res.RowsReturned() == 0 { 15 | fmt.Println("creating database") 16 | statement = fmt.Sprintf(`CREATE DATABASE %s WITH OWNER %s;`, p.Database, p.User) 17 | _, err := db.Exec(statement) 18 | if err != nil { 19 | fmt.Println(err) 20 | } else { 21 | fmt.Printf(`Created database %s`, p.Database) 22 | } 23 | } else { 24 | fmt.Printf("Database named %s already exists\n", p.Database) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /manager/createdbuser.go: -------------------------------------------------------------------------------- 1 | package manager 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/go-pg/pg/v9" 7 | "github.com/gogjango/gjango/config" 8 | ) 9 | 10 | // CreateDatabaseUserIfNotExist creates a database user 11 | func CreateDatabaseUserIfNotExist(db *pg.DB, p *config.PostgresConfig) { 12 | statement := fmt.Sprintf(`SELECT * FROM pg_roles WHERE rolname = '%s';`, p.User) 13 | res, _ := db.Exec(statement) 14 | if res.RowsReturned() == 0 { 15 | statement = fmt.Sprintf(`CREATE USER %s WITH PASSWORD '%s';`, p.User, p.Password) 16 | _, err := db.Exec(statement) 17 | if err != nil { 18 | fmt.Println(err) 19 | } else { 20 | fmt.Printf(`Created user %s`, p.User) 21 | } 22 | } else { 23 | fmt.Printf("Database user %s already exists\n", p.User) 24 | } 25 | 26 | } 27 | -------------------------------------------------------------------------------- /manager/createschema.go: -------------------------------------------------------------------------------- 1 | package manager 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/go-pg/pg/v9" 7 | "github.com/go-pg/pg/v9/orm" 8 | ) 9 | 10 | // CreateSchema creates the tables for given models 11 | func CreateSchema(db *pg.DB, models ...interface{}) { 12 | for _, model := range models { 13 | opt := &orm.CreateTableOptions{ 14 | IfNotExists: true, 15 | FKConstraints: true, 16 | } 17 | err := db.CreateTable(model, opt) 18 | if err != nil { 19 | log.Fatal(err) 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /manager/manager.go: -------------------------------------------------------------------------------- 1 | package manager 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "reflect" 7 | "strings" 8 | 9 | "github.com/gertd/go-pluralize" 10 | "github.com/go-pg/pg/v9" 11 | "github.com/go-pg/pg/v9/orm" 12 | "github.com/gogjango/gjango/model" 13 | "github.com/gogjango/gjango/repository" 14 | "github.com/gogjango/gjango/secret" 15 | ) 16 | 17 | // NewManager returns a new manager 18 | func NewManager(accountRepo *repository.AccountRepo, roleRepo *repository.RoleRepo, db *pg.DB) *Manager { 19 | return &Manager{accountRepo, roleRepo, db} 20 | } 21 | 22 | // Manager holds a group of methods for writing tests 23 | type Manager struct { 24 | accountRepo *repository.AccountRepo 25 | roleRepo *repository.RoleRepo 26 | db *pg.DB 27 | } 28 | 29 | // CreateSchema creates tables declared as models (struct) 30 | func (m *Manager) CreateSchema(models ...interface{}) { 31 | for _, model := range models { 32 | opt := &orm.CreateTableOptions{ 33 | IfNotExists: true, 34 | FKConstraints: true, 35 | } 36 | err := m.db.CreateTable(model, opt) 37 | if err != nil { 38 | log.Fatal(err) 39 | } 40 | p := pluralize.NewClient() 41 | modelName := GetType(model) 42 | tableName := p.Plural(strings.ToLower(modelName)) 43 | fmt.Printf("Created model %s as table %s\n", modelName, tableName) 44 | } 45 | } 46 | 47 | // CreateRoles is a thin wrapper for roleRepo.CreateRoles(), which populates our roles table 48 | func (m *Manager) CreateRoles() { 49 | err := m.roleRepo.CreateRoles() 50 | if err != nil { 51 | log.Fatal(err) 52 | } 53 | } 54 | 55 | // CreateSuperAdmin is used to create a user object with superadmin role 56 | func (m *Manager) CreateSuperAdmin(email, password string) (*model.User, error) { 57 | u := &model.User{ 58 | Email: email, 59 | Password: secret.New().HashPassword(password), 60 | Active: true, 61 | Verified: true, 62 | RoleID: int(model.SuperAdminRole), 63 | } 64 | return m.accountRepo.Create(u) 65 | } 66 | 67 | // GetType is a useful utility function to help us inspect the name of a model (struct) which is expressed as an interface{} 68 | func GetType(myvar interface{}) string { 69 | valueOf := reflect.ValueOf(myvar) 70 | if valueOf.Type().Kind() == reflect.Ptr { 71 | return reflect.Indirect(valueOf).Type().Name() 72 | } 73 | return valueOf.Type().Name() 74 | } 75 | 76 | // GetModels retrieve models 77 | func GetModels() []interface{} { 78 | return model.Models 79 | } 80 | -------------------------------------------------------------------------------- /middleware/jwt.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "net/http" 5 | "strings" 6 | "time" 7 | 8 | jwt "github.com/dgrijalva/jwt-go" 9 | "github.com/gin-gonic/gin" 10 | "github.com/gogjango/gjango/apperr" 11 | "github.com/gogjango/gjango/config" 12 | "github.com/gogjango/gjango/model" 13 | ) 14 | 15 | // NewJWT generates new JWT variable necessery for auth middleware 16 | func NewJWT(c *config.JWT) *JWT { 17 | return &JWT{ 18 | Realm: c.Realm, 19 | Key: []byte(c.Secret), 20 | Duration: time.Duration(c.Duration) * time.Minute, 21 | Algo: c.SigningAlgorithm, 22 | } 23 | } 24 | 25 | // JWT provides a Json-Web-Token authentication implementation 26 | type JWT struct { 27 | // Realm name to display to the user. 28 | Realm string 29 | 30 | // Secret key used for signing. 31 | Key []byte 32 | 33 | // Duration for which the jwt token is valid. 34 | Duration time.Duration 35 | 36 | // JWT signing algorithm 37 | Algo string 38 | } 39 | 40 | // MWFunc makes JWT implement the Middleware interface. 41 | func (j *JWT) MWFunc() gin.HandlerFunc { 42 | 43 | return func(c *gin.Context) { 44 | token, err := j.ParseToken(c) 45 | if err != nil || !token.Valid { 46 | c.Header("WWW-Authenticate", "JWT realm="+j.Realm) 47 | c.AbortWithStatus(http.StatusUnauthorized) 48 | return 49 | } 50 | 51 | claims := token.Claims.(jwt.MapClaims) 52 | 53 | id := int(claims["id"].(float64)) 54 | companyID := int(claims["c"].(float64)) 55 | locationID := int(claims["l"].(float64)) 56 | username := claims["u"].(string) 57 | email := claims["e"].(string) 58 | role := int8(claims["r"].(float64)) 59 | 60 | c.Set("id", id) 61 | c.Set("company_id", companyID) 62 | c.Set("location_id", locationID) 63 | c.Set("username", username) 64 | c.Set("email", email) 65 | c.Set("role", role) 66 | 67 | c.Next() 68 | } 69 | } 70 | 71 | // ParseToken parses token from Authorization header 72 | func (j *JWT) ParseToken(c *gin.Context) (*jwt.Token, error) { 73 | 74 | token := c.Request.Header.Get("Authorization") 75 | if token == "" { 76 | return nil, apperr.Unauthorized 77 | } 78 | parts := strings.SplitN(token, " ", 2) 79 | if !(len(parts) == 2 && parts[0] == "Bearer") { 80 | return nil, apperr.Unauthorized 81 | } 82 | 83 | return jwt.Parse(parts[1], func(token *jwt.Token) (interface{}, error) { 84 | if jwt.GetSigningMethod(j.Algo) != token.Method { 85 | return nil, apperr.Generic 86 | } 87 | return j.Key, nil 88 | }) 89 | 90 | } 91 | 92 | // GenerateToken generates new JWT token and populates it with user data 93 | func (j *JWT) GenerateToken(u *model.User) (string, string, error) { 94 | token := jwt.New(jwt.GetSigningMethod(j.Algo)) 95 | claims := token.Claims.(jwt.MapClaims) 96 | 97 | expire := time.Now().Add(j.Duration) 98 | claims["id"] = u.ID 99 | claims["u"] = u.Username 100 | claims["e"] = u.Email 101 | claims["r"] = u.Role.AccessLevel 102 | claims["c"] = u.CompanyID 103 | claims["l"] = u.LocationID 104 | claims["exp"] = expire.Unix() 105 | 106 | tokenString, err := token.SignedString(j.Key) 107 | return tokenString, expire.Format(time.RFC3339), err 108 | } 109 | -------------------------------------------------------------------------------- /middleware/jwt_test.go: -------------------------------------------------------------------------------- 1 | package middleware_test 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/gin-gonic/gin" 10 | "github.com/gogjango/gjango/config" 11 | mw "github.com/gogjango/gjango/middleware" 12 | "github.com/gogjango/gjango/mock" 13 | "github.com/gogjango/gjango/model" 14 | "github.com/stretchr/testify/assert" 15 | ) 16 | 17 | func hwHandler(c *gin.Context) { 18 | c.JSON(200, gin.H{ 19 | "text": "Hello World.", 20 | }) 21 | } 22 | 23 | func ginHandler(mw ...gin.HandlerFunc) *gin.Engine { 24 | gin.SetMode(gin.TestMode) 25 | r := gin.New() 26 | for _, v := range mw { 27 | r.Use(v) 28 | } 29 | r.GET("/hello", hwHandler) 30 | return r 31 | } 32 | 33 | func TestMWFunc(t *testing.T) { 34 | cases := []struct { 35 | name string 36 | wantStatus int 37 | header string 38 | }{ 39 | { 40 | name: "Empty header", 41 | wantStatus: http.StatusUnauthorized, 42 | }, 43 | { 44 | name: "Header not containing Bearer", 45 | header: "notBearer", 46 | wantStatus: http.StatusUnauthorized, 47 | }, 48 | { 49 | name: "Invalid header", 50 | header: mock.HeaderInvalid(), 51 | wantStatus: http.StatusUnauthorized, 52 | }, 53 | { 54 | name: "Success", 55 | header: mock.HeaderValid(), 56 | wantStatus: http.StatusOK, 57 | }, 58 | } 59 | jwtCfg := &config.JWT{Realm: "testRealm", Secret: "jwtsecret", Duration: 60, SigningAlgorithm: "HS256"} 60 | jwtMW := mw.NewJWT(jwtCfg) 61 | ts := httptest.NewServer(ginHandler(jwtMW.MWFunc())) 62 | defer ts.Close() 63 | path := ts.URL + "/hello" 64 | client := &http.Client{} 65 | 66 | for _, tt := range cases { 67 | t.Run(tt.name, func(t *testing.T) { 68 | req, _ := http.NewRequest("GET", path, nil) 69 | req.Header.Set("Authorization", tt.header) 70 | res, err := client.Do(req) 71 | if err != nil { 72 | t.Fatal("Cannot create http request") 73 | } 74 | assert.Equal(t, tt.wantStatus, res.StatusCode) 75 | }) 76 | } 77 | } 78 | 79 | func TestGenerateToken(t *testing.T) { 80 | cases := []struct { 81 | name string 82 | wantToken string 83 | req *model.User 84 | }{ 85 | { 86 | name: "Success", 87 | req: &model.User{ 88 | Base: model.Base{}, 89 | ID: 1, 90 | Username: "johndoe", 91 | Email: "johndoe@mail.com", 92 | Role: &model.Role{ 93 | AccessLevel: model.SuperAdminRole, 94 | }, 95 | CompanyID: 1, 96 | LocationID: 1, 97 | }, 98 | wantToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9", 99 | }, 100 | } 101 | jwtCfg := &config.JWT{Realm: "testRealm", Secret: "jwtsecret", Duration: 60, SigningAlgorithm: "HS256"} 102 | 103 | for _, tt := range cases { 104 | t.Run(tt.name, func(t *testing.T) { 105 | jwt := mw.NewJWT(jwtCfg) 106 | str, _, err := jwt.GenerateToken(tt.req) 107 | assert.Nil(t, err) 108 | assert.Equal(t, tt.wantToken, strings.Split(str, ".")[0]) 109 | }) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /middleware/middleware.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import "github.com/gin-gonic/gin" 4 | 5 | // Add adds middlewares to gin engine 6 | func Add(r *gin.Engine, h ...gin.HandlerFunc) { 7 | for _, v := range h { 8 | r.Use(v) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /middleware/middleware_test.go: -------------------------------------------------------------------------------- 1 | package middleware_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/gin-gonic/gin" 7 | mw "github.com/gogjango/gjango/middleware" 8 | ) 9 | 10 | func TestAdd(t *testing.T) { 11 | gin.SetMode(gin.TestMode) 12 | r := gin.New() 13 | mw.Add(r, gin.Logger()) 14 | } 15 | -------------------------------------------------------------------------------- /migration/migration.go: -------------------------------------------------------------------------------- 1 | package migration 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "log" 7 | "os" 8 | 9 | migrations "github.com/go-pg/migrations/v7" 10 | "github.com/go-pg/pg/v9" 11 | "github.com/go-pg/pg/v9/orm" 12 | "github.com/gogjango/gjango/config" 13 | "github.com/gogjango/gjango/model" 14 | ) 15 | 16 | const usageText = `This program runs command on the db. Supported commands are: 17 | - init - creates version info table in the database 18 | - up - runs all available migrations. 19 | - up [target] - runs available migrations up to the target one. 20 | - down - reverts last migration. 21 | - reset - reverts all migrations. 22 | - version - prints current db version. 23 | - set_version [version] - sets db version without running migrations. 24 | - create_schema [version] - creates initial set of tables from models (structs). 25 | Usage: 26 | go run *.go [args] 27 | ` 28 | 29 | // Run executes migration subcommands 30 | func Run(args ...string) error { 31 | fmt.Println("Running migration") 32 | 33 | p := config.GetPostgresConfig() 34 | 35 | // connection to db as postgres superuser 36 | dbSuper := config.GetPostgresSuperUserConnection() 37 | defer dbSuper.Close() 38 | 39 | // connection to db as POSTGRES_USER 40 | db := config.GetConnection() 41 | defer db.Close() 42 | 43 | createUserIfNotExist(dbSuper, p) 44 | 45 | createDatabaseIfNotExist(dbSuper, p) 46 | 47 | if flag.Arg(0) == "create_schema" { 48 | createSchema(db, &model.Company{}, &model.Location{}, &model.Role{}, &model.User{}, &model.Verification{}) 49 | os.Exit(2) 50 | } 51 | 52 | oldVersion, newVersion, err := migrations.Run(db, args...) 53 | if err != nil { 54 | exitf(err.Error()) 55 | } 56 | if newVersion != oldVersion { 57 | fmt.Printf("migrated from version %d to %d\n", oldVersion, newVersion) 58 | } else { 59 | fmt.Printf("version is %d\n", oldVersion) 60 | } 61 | return nil 62 | } 63 | 64 | func usage() { 65 | fmt.Print(usageText) 66 | flag.PrintDefaults() 67 | os.Exit(2) 68 | } 69 | 70 | func errorf(s string, args ...interface{}) { 71 | fmt.Fprintf(os.Stderr, s+"\n", args...) 72 | } 73 | 74 | func exitf(s string, args ...interface{}) { 75 | errorf(s, args...) 76 | os.Exit(1) 77 | } 78 | 79 | func createUserIfNotExist(db *pg.DB, p *config.PostgresConfig) { 80 | statement := fmt.Sprintf(`SELECT * FROM pg_roles WHERE rolname = '%s';`, p.User) 81 | res, _ := db.Exec(statement) 82 | if res.RowsReturned() == 0 { 83 | statement = fmt.Sprintf(`CREATE USER %s WITH PASSWORD '%s';`, p.User, p.Password) 84 | _, err := db.Exec(statement) 85 | if err != nil { 86 | fmt.Println(err) 87 | } else { 88 | fmt.Printf(`Created user %s`, p.User) 89 | } 90 | } 91 | } 92 | 93 | func createDatabaseIfNotExist(db *pg.DB, p *config.PostgresConfig) { 94 | statement := fmt.Sprintf(`SELECT 1 AS result FROM pg_database WHERE datname = '%s';`, p.Database) 95 | res, _ := db.Exec(statement) 96 | if res.RowsReturned() == 0 { 97 | fmt.Println("creating database") 98 | statement = fmt.Sprintf(`CREATE DATABASE %s WITH OWNER %s;`, p.Database, p.User) 99 | _, err := db.Exec(statement) 100 | if err != nil { 101 | fmt.Println(err) 102 | } else { 103 | fmt.Printf(`Created database %s`, p.Database) 104 | } 105 | } 106 | } 107 | 108 | func createSchema(db *pg.DB, models ...interface{}) { 109 | for _, model := range models { 110 | opt := &orm.CreateTableOptions{ 111 | IfNotExists: true, 112 | FKConstraints: true, 113 | } 114 | err := db.CreateTable(model, opt) 115 | if err != nil { 116 | log.Fatal(err) 117 | } 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /mobile/mobile.go: -------------------------------------------------------------------------------- 1 | package mobile 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "log" 7 | "net/http" 8 | "net/url" 9 | "strconv" 10 | "strings" 11 | 12 | "github.com/gogjango/gjango/config" 13 | ) 14 | 15 | // NewMobile creates a new mobile service implementation 16 | func NewMobile(config *config.TwilioConfig) *Mobile { 17 | return &Mobile{config} 18 | } 19 | 20 | // Mobile provides a mobile service implementation 21 | type Mobile struct { 22 | config *config.TwilioConfig 23 | } 24 | 25 | // GenerateSMSToken sends an sms token to the mobile numer 26 | // func (m *Mobile) GenerateSMSToken(countryCode, mobile string) error 27 | // m.GenerateSMSToken("+65", "90901299") 28 | func (m *Mobile) GenerateSMSToken(countryCode, mobile string) error { 29 | apiURL := m.getTwilioVerifyURL() 30 | data := url.Values{} 31 | data.Set("To", countryCode+mobile) 32 | data.Set("Channel", "sms") 33 | resp, err := m.send(apiURL, data) 34 | if err != nil { 35 | return err 36 | } 37 | 38 | bodyBytes, err := ioutil.ReadAll(resp.Body) 39 | if err != nil { 40 | log.Fatal(err) 41 | } 42 | bodyString := string(bodyBytes) 43 | fmt.Println(bodyString) 44 | return err 45 | } 46 | 47 | // CheckCode verifies if the user-provided code is approved 48 | func (m *Mobile) CheckCode(countryCode, mobile, code string) error { 49 | apiURL := m.getTwilioVerifyURL() 50 | data := url.Values{} 51 | data.Set("To", countryCode+mobile) 52 | data.Set("Code", code) 53 | resp, err := m.send(apiURL, data) 54 | if err != nil { 55 | return err 56 | } 57 | 58 | // take a look at our response 59 | fmt.Println(resp.StatusCode) 60 | fmt.Println(resp.Body) 61 | bodyBytes, err := ioutil.ReadAll(resp.Body) 62 | if err != nil { 63 | log.Fatal(err) 64 | } 65 | bodyString := string(bodyBytes) 66 | fmt.Println(bodyString) 67 | return nil 68 | } 69 | 70 | func (m *Mobile) getTwilioVerifyURL() string { 71 | return "https://verify.twilio.com/v2/Services/" + m.config.Verify + "/Verifications" 72 | } 73 | 74 | func (m *Mobile) send(apiURL string, data url.Values) (*http.Response, error) { 75 | u, _ := url.ParseRequestURI(apiURL) 76 | urlStr := u.String() 77 | // http client 78 | client := &http.Client{} 79 | r, _ := http.NewRequest("POST", urlStr, strings.NewReader(data.Encode())) // URL-encoded payload 80 | r.SetBasicAuth(m.config.Account, m.config.Token) 81 | r.Header.Add("Content-Type", "application/x-www-form-urlencoded") 82 | r.Header.Add("Content-Length", strconv.Itoa(len(data.Encode()))) 83 | 84 | return client.Do(r) 85 | } 86 | -------------------------------------------------------------------------------- /mobile/mobile_interface.go: -------------------------------------------------------------------------------- 1 | package mobile 2 | 3 | // Service is the interface to our mobile service 4 | type Service interface { 5 | GenerateSMSToken(countryCode, mobile string) error 6 | CheckCode(countryCode, mobile, code string) error 7 | } 8 | -------------------------------------------------------------------------------- /mock/auth.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "github.com/gogjango/gjango/model" 6 | ) 7 | 8 | // Auth mock 9 | type Auth struct { 10 | UserFn func(*gin.Context) *model.AuthUser 11 | } 12 | 13 | // User mock 14 | func (a *Auth) User(c *gin.Context) *model.AuthUser { 15 | return a.UserFn(c) 16 | } 17 | -------------------------------------------------------------------------------- /mock/mail.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import "github.com/gogjango/gjango/model" 4 | 5 | // Mail mock 6 | type Mail struct { 7 | ExternalURL string 8 | SendFn func(string, string, string, string) error 9 | SendWithDefaultsFn func(string, string, string) error 10 | SendVerificationEmailFn func(string, *model.Verification) error 11 | } 12 | 13 | // Send mock 14 | func (m *Mail) Send(subject, toName, toEmail, content string) error { 15 | return m.SendFn(subject, toName, toEmail, content) 16 | } 17 | 18 | // SendWithDefaults mock 19 | func (m *Mail) SendWithDefaults(subject, toEmail, content string) error { 20 | return m.SendWithDefaultsFn(subject, toEmail, content) 21 | } 22 | 23 | // SendVerificationEmail mock 24 | func (m *Mail) SendVerificationEmail(toEmail string, v *model.Verification) error { 25 | return m.SendVerificationEmailFn(toEmail, v) 26 | } 27 | -------------------------------------------------------------------------------- /mock/middleware.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import "github.com/gogjango/gjango/model" 4 | 5 | // JWT mock 6 | type JWT struct { 7 | GenerateTokenFn func(*model.User) (string, string, error) 8 | } 9 | 10 | // GenerateToken mock 11 | func (j *JWT) GenerateToken(u *model.User) (string, string, error) { 12 | return j.GenerateTokenFn(u) 13 | } 14 | -------------------------------------------------------------------------------- /mock/mobile.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | // Mobile mock 4 | type Mobile struct { 5 | GenerateSMSTokenFn func(string, string) error 6 | CheckCodeFn func(string, string, string) error 7 | } 8 | 9 | // GenerateSMSToken mock 10 | func (m *Mobile) GenerateSMSToken(countryCode, mobile string) error { 11 | return m.GenerateSMSTokenFn(countryCode, mobile) 12 | } 13 | 14 | // CheckCode mock 15 | func (m *Mobile) CheckCode(countryCode, mobile, code string) error { 16 | return m.CheckCodeFn(countryCode, mobile, code) 17 | } 18 | -------------------------------------------------------------------------------- /mock/mock.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import ( 4 | "net/http/httptest" 5 | "time" 6 | 7 | "github.com/gin-gonic/gin" 8 | ) 9 | 10 | // TestTime is used for testing time fields 11 | func TestTime(year int) time.Time { 12 | return time.Date(year, time.May, 19, 1, 2, 3, 4, time.UTC) 13 | } 14 | 15 | // TestTimePtr is used for testing pointer time fields 16 | func TestTimePtr(year int) *time.Time { 17 | t := time.Date(year, time.May, 19, 1, 2, 3, 4, time.UTC) 18 | return &t 19 | } 20 | 21 | // Str2Ptr converts string to pointer 22 | func Str2Ptr(s string) *string { 23 | return &s 24 | } 25 | 26 | // GinCtxWithKeys returns new gin context with keys 27 | func GinCtxWithKeys(keys []string, values ...interface{}) *gin.Context { 28 | w := httptest.NewRecorder() 29 | gin.SetMode(gin.TestMode) 30 | c, _ := gin.CreateTestContext(w) 31 | for i, k := range keys { 32 | c.Set(k, values[i]) 33 | } 34 | return c 35 | } 36 | 37 | // HeaderValid is used for jwt testing 38 | func HeaderValid() string { 39 | return "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidSI6ImpvaG5kb2UiLCJlIjoiam9obmRvZUBtYWlsLmNvbSIsInIiOjEsImMiOjEsImwiOjEsImV4cCI6NDEwOTMyMDg5NCwiaWF0IjoxNTE2MjM5MDIyfQ.8Fa8mhshx3tiQVzS5FoUXte5lHHC4cvaa_tzvcel38I" 40 | } 41 | 42 | // HeaderInvalid is used for jwt testing 43 | func HeaderInvalid() string { 44 | return "Bearer eyJhbGciOiJIUzM4NCIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidSI6ImpvaG5kb2UiLCJlIjoiam9obmRvZUBtYWlsLmNvbSIsInIiOjEsImMiOjEsImwiOjEsImV4cCI6NDEwOTMyMDg5NCwiaWF0IjoxNTE2MjM5MDIyfQ.7uPfVeZBkkyhICZSEINZfPo7ZsaY0NNeg0ebEGHuAvNjFvoKNn8dWYTKaZrqE1X4" 45 | } 46 | -------------------------------------------------------------------------------- /mock/mockdb/account.go: -------------------------------------------------------------------------------- 1 | package mockdb 2 | 3 | import ( 4 | "github.com/gogjango/gjango/model" 5 | ) 6 | 7 | // Account database mock 8 | type Account struct { 9 | CreateFn func(*model.User) (*model.User, error) 10 | CreateAndVerifyFn func(*model.User) (*model.Verification, error) 11 | CreateWithMobileFn func(*model.User) error 12 | ChangePasswordFn func(*model.User) error 13 | FindVerificationTokenFn func(string) (*model.Verification, error) 14 | DeleteVerificationTokenFn func(*model.Verification) error 15 | } 16 | 17 | // Create mock 18 | func (a *Account) Create(usr *model.User) (*model.User, error) { 19 | return a.CreateFn(usr) 20 | } 21 | 22 | // CreateAndVerify mock 23 | func (a *Account) CreateAndVerify(usr *model.User) (*model.Verification, error) { 24 | return a.CreateAndVerifyFn(usr) 25 | } 26 | 27 | // CreateWithMobile mock 28 | func (a *Account) CreateWithMobile(usr *model.User) error { 29 | return a.CreateWithMobileFn(usr) 30 | } 31 | 32 | // ChangePassword mock 33 | func (a *Account) ChangePassword(usr *model.User) error { 34 | return a.ChangePasswordFn(usr) 35 | } 36 | 37 | // FindVerificationToken mock 38 | func (a *Account) FindVerificationToken(token string) (*model.Verification, error) { 39 | return a.FindVerificationTokenFn(token) 40 | } 41 | 42 | // DeleteVerificationToken mock 43 | func (a *Account) DeleteVerificationToken(v *model.Verification) error { 44 | return a.DeleteVerificationTokenFn(v) 45 | } 46 | -------------------------------------------------------------------------------- /mock/mockdb/user.go: -------------------------------------------------------------------------------- 1 | package mockdb 2 | 3 | import ( 4 | "github.com/gogjango/gjango/model" 5 | ) 6 | 7 | // User database mock 8 | type User struct { 9 | ViewFn func(int) (*model.User, error) 10 | FindByUsernameFn func(string) (*model.User, error) 11 | FindByEmailFn func(string) (*model.User, error) 12 | FindByMobileFn func(string, string) (*model.User, error) 13 | FindByTokenFn func(string) (*model.User, error) 14 | UpdateLoginFn func(*model.User) error 15 | ListFn func(*model.ListQuery, *model.Pagination) ([]model.User, error) 16 | DeleteFn func(*model.User) error 17 | UpdateFn func(*model.User) (*model.User, error) 18 | } 19 | 20 | // View mock 21 | func (u *User) View(id int) (*model.User, error) { 22 | return u.ViewFn(id) 23 | } 24 | 25 | // FindByUsername mock 26 | func (u *User) FindByUsername(username string) (*model.User, error) { 27 | return u.FindByUsernameFn(username) 28 | } 29 | 30 | // FindByEmail mock 31 | func (u *User) FindByEmail(email string) (*model.User, error) { 32 | return u.FindByEmailFn(email) 33 | } 34 | 35 | // FindByMobile mock 36 | func (u *User) FindByMobile(countryCode, mobile string) (*model.User, error) { 37 | return u.FindByMobileFn(countryCode, mobile) 38 | } 39 | 40 | // FindByToken mock 41 | func (u *User) FindByToken(token string) (*model.User, error) { 42 | return u.FindByTokenFn(token) 43 | } 44 | 45 | // UpdateLogin mock 46 | func (u *User) UpdateLogin(usr *model.User) error { 47 | return u.UpdateLoginFn(usr) 48 | } 49 | 50 | // List mock 51 | func (u *User) List(lq *model.ListQuery, p *model.Pagination) ([]model.User, error) { 52 | return u.ListFn(lq, p) 53 | } 54 | 55 | // Delete mock 56 | func (u *User) Delete(usr *model.User) error { 57 | return u.DeleteFn(usr) 58 | } 59 | 60 | // Update mock 61 | func (u *User) Update(usr *model.User) (*model.User, error) { 62 | return u.UpdateFn(usr) 63 | } 64 | -------------------------------------------------------------------------------- /mock/rbac.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "github.com/gogjango/gjango/model" 6 | ) 7 | 8 | // RBAC Mock 9 | type RBAC struct { 10 | EnforceRoleFn func(*gin.Context, model.AccessRole) bool 11 | EnforceUserFn func(*gin.Context, int) bool 12 | EnforceCompanyFn func(*gin.Context, int) bool 13 | EnforceLocationFn func(*gin.Context, int) bool 14 | AccountCreateFn func(*gin.Context, int, int, int) bool 15 | IsLowerRoleFn func(*gin.Context, model.AccessRole) bool 16 | } 17 | 18 | // EnforceRole mock 19 | func (a *RBAC) EnforceRole(c *gin.Context, role model.AccessRole) bool { 20 | return a.EnforceRoleFn(c, role) 21 | } 22 | 23 | // EnforceUser mock 24 | func (a *RBAC) EnforceUser(c *gin.Context, id int) bool { 25 | return a.EnforceUserFn(c, id) 26 | } 27 | 28 | // EnforceCompany mock 29 | func (a *RBAC) EnforceCompany(c *gin.Context, id int) bool { 30 | return a.EnforceCompanyFn(c, id) 31 | } 32 | 33 | // EnforceLocation mock 34 | func (a *RBAC) EnforceLocation(c *gin.Context, id int) bool { 35 | return a.EnforceLocationFn(c, id) 36 | } 37 | 38 | // AccountCreate mock 39 | func (a *RBAC) AccountCreate(c *gin.Context, roleID, companyID, locationID int) bool { 40 | return a.AccountCreateFn(c, roleID, companyID, locationID) 41 | } 42 | 43 | // IsLowerRole mock 44 | func (a *RBAC) IsLowerRole(c *gin.Context, role model.AccessRole) bool { 45 | return a.IsLowerRoleFn(c, role) 46 | } 47 | -------------------------------------------------------------------------------- /mock/secret.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | // Password mock 4 | type Password struct { 5 | HashPasswordFn func(string) string 6 | HashMatchesPasswordFn func(hash, password string) bool 7 | HashRandomPasswordFn func() (string, error) 8 | } 9 | 10 | // HashPassword mock 11 | func (p *Password) HashPassword(password string) string { 12 | return p.HashPasswordFn(password) 13 | } 14 | 15 | // HashMatchesPassword mock 16 | func (p *Password) HashMatchesPassword(hash, password string) bool { 17 | return p.HashMatchesPasswordFn(hash, password) 18 | } 19 | 20 | // HashRandomPassword mock 21 | func (p *Password) HashRandomPassword() (string, error) { 22 | return p.HashRandomPasswordFn() 23 | } 24 | -------------------------------------------------------------------------------- /mockgopg/build_insert.go: -------------------------------------------------------------------------------- 1 | package mockgopg 2 | 3 | type buildInsert struct { 4 | insert string 5 | err error 6 | } 7 | -------------------------------------------------------------------------------- /mockgopg/build_query.go: -------------------------------------------------------------------------------- 1 | package mockgopg 2 | 3 | type buildQuery struct { 4 | funcName string 5 | query string 6 | params []interface{} 7 | result *OrmResult 8 | err error 9 | } 10 | -------------------------------------------------------------------------------- /mockgopg/formatter.go: -------------------------------------------------------------------------------- 1 | package mockgopg 2 | 3 | import "github.com/go-pg/pg/v9/orm" 4 | 5 | // Formatter implements orm.Formatter 6 | type Formatter struct { 7 | } 8 | 9 | // FormatQuery formats our query and params to byte 10 | func (f *Formatter) FormatQuery(b []byte, query string, params ...interface{}) []byte { 11 | formatter := new(orm.Formatter) 12 | got := formatter.FormatQuery(b, query, params...) 13 | return got 14 | } 15 | -------------------------------------------------------------------------------- /mockgopg/mock.go: -------------------------------------------------------------------------------- 1 | package mockgopg 2 | 3 | import ( 4 | "strings" 5 | "sync" 6 | 7 | "github.com/gogjango/gjango/manager" 8 | ) 9 | 10 | // SQLMock handles query mocks 11 | type SQLMock struct { 12 | lock *sync.RWMutex 13 | currentQuery string // tracking queries 14 | currentParams []interface{} 15 | queries map[string]buildQuery 16 | currentInsert string // tracking inserts 17 | inserts map[string]buildInsert 18 | } 19 | 20 | // ExpectInsert is a builder method that accepts a model as interface and returns an SQLMock pointer 21 | func (sqlMock *SQLMock) ExpectInsert(models ...interface{}) *SQLMock { 22 | sqlMock.lock.Lock() 23 | defer sqlMock.lock.Unlock() 24 | 25 | var inserts []string 26 | for _, v := range models { 27 | inserts = append(inserts, strings.ToLower(manager.GetType(v))) 28 | } 29 | currentInsert := strings.Join(inserts, ",") 30 | 31 | sqlMock.currentInsert = currentInsert 32 | return sqlMock 33 | } 34 | 35 | // ExpectExec is a builder method that accepts a query in string and returns an SQLMock pointer 36 | func (sqlMock *SQLMock) ExpectExec(query string) *SQLMock { 37 | sqlMock.lock.Lock() 38 | defer sqlMock.lock.Unlock() 39 | 40 | sqlMock.currentQuery = strings.TrimSpace(query) 41 | return sqlMock 42 | } 43 | 44 | // ExpectQuery accepts a query in string and returns an SQLMock pointer 45 | func (sqlMock *SQLMock) ExpectQuery(query string) *SQLMock { 46 | sqlMock.lock.Lock() 47 | defer sqlMock.lock.Unlock() 48 | 49 | sqlMock.currentQuery = strings.TrimSpace(query) 50 | return sqlMock 51 | } 52 | 53 | // ExpectQueryOne accepts a query in string and returns an SQLMock pointer 54 | func (sqlMock *SQLMock) ExpectQueryOne(query string) *SQLMock { 55 | sqlMock.lock.Lock() 56 | defer sqlMock.lock.Unlock() 57 | 58 | sqlMock.currentQuery = strings.TrimSpace(query) 59 | return sqlMock 60 | } 61 | 62 | // WithArgs is a builder method that accepts a query in string and returns an SQLMock pointer 63 | func (sqlMock *SQLMock) WithArgs(params ...interface{}) *SQLMock { 64 | sqlMock.lock.Lock() 65 | defer sqlMock.lock.Unlock() 66 | 67 | sqlMock.currentParams = make([]interface{}, 0) 68 | for _, p := range params { 69 | sqlMock.currentParams = append(sqlMock.currentParams, p) 70 | } 71 | 72 | return sqlMock 73 | } 74 | 75 | // Returns accepts expected result and error, and completes the build of our sqlMock object 76 | func (sqlMock *SQLMock) Returns(result *OrmResult, err error) { 77 | sqlMock.lock.Lock() 78 | defer sqlMock.lock.Unlock() 79 | 80 | q := buildQuery{ 81 | query: sqlMock.currentQuery, 82 | params: sqlMock.currentParams, 83 | result: result, 84 | err: err, 85 | } 86 | sqlMock.queries[sqlMock.currentQuery] = q 87 | sqlMock.currentQuery = "" 88 | sqlMock.currentParams = nil 89 | 90 | i := buildInsert{ 91 | insert: sqlMock.currentInsert, 92 | err: err, 93 | } 94 | sqlMock.inserts[sqlMock.currentInsert] = i 95 | sqlMock.currentInsert = "" 96 | } 97 | 98 | // FlushAll resets our sqlMock object 99 | func (sqlMock *SQLMock) FlushAll() { 100 | sqlMock.lock.Lock() 101 | defer sqlMock.lock.Unlock() 102 | 103 | sqlMock.currentQuery = "" 104 | sqlMock.currentParams = nil 105 | sqlMock.queries = make(map[string]buildQuery) 106 | 107 | sqlMock.currentInsert = "" 108 | sqlMock.inserts = make(map[string]buildInsert) 109 | } 110 | -------------------------------------------------------------------------------- /mockgopg/orm.go: -------------------------------------------------------------------------------- 1 | package mockgopg 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "regexp" 9 | "strings" 10 | "sync" 11 | 12 | "github.com/go-pg/pg/v9/orm" 13 | "github.com/gogjango/gjango/manager" 14 | ) 15 | 16 | type goPgDB struct { 17 | sqlMock *SQLMock 18 | } 19 | 20 | // NewGoPGDBTest returns method that already implements orm.DB and mock instance to mocking arguments and results. 21 | func NewGoPGDBTest() (conn orm.DB, mock *SQLMock, err error) { 22 | sqlMock := &SQLMock{ 23 | lock: new(sync.RWMutex), 24 | currentQuery: "", 25 | currentParams: nil, 26 | queries: make(map[string]buildQuery), 27 | currentInsert: "", 28 | inserts: make(map[string]buildInsert), 29 | } 30 | 31 | goPG := &goPgDB{ 32 | sqlMock: sqlMock, 33 | } 34 | 35 | return goPG, sqlMock, nil 36 | } 37 | 38 | // not yet implemented 39 | func (p *goPgDB) Model(model ...interface{}) *orm.Query { 40 | return nil 41 | } 42 | 43 | func (p *goPgDB) ModelContext(c context.Context, model ...interface{}) *orm.Query { 44 | return nil 45 | } 46 | 47 | func (p *goPgDB) Select(model interface{}) error { 48 | return nil 49 | } 50 | 51 | func (p *goPgDB) Insert(model ...interface{}) error { 52 | // return nil 53 | return p.doInsert(context.Background(), model...) 54 | } 55 | 56 | func (p *goPgDB) Update(model interface{}) error { 57 | return nil 58 | } 59 | 60 | func (p *goPgDB) Delete(model interface{}) error { 61 | return nil 62 | } 63 | 64 | func (p *goPgDB) ForceDelete(model interface{}) error { 65 | return nil 66 | } 67 | 68 | func (p *goPgDB) Exec(query interface{}, params ...interface{}) (orm.Result, error) { 69 | sqlQuery := fmt.Sprintf("%v", query) 70 | return p.doQuery(context.Background(), nil, sqlQuery, params...) 71 | } 72 | 73 | func (p *goPgDB) ExecContext(c context.Context, query interface{}, params ...interface{}) (orm.Result, error) { 74 | sqlQuery := fmt.Sprintf("%v", query) 75 | return p.doQuery(c, nil, sqlQuery, params...) 76 | } 77 | 78 | func (p *goPgDB) ExecOne(query interface{}, params ...interface{}) (orm.Result, error) { 79 | return nil, nil 80 | } 81 | 82 | func (p *goPgDB) ExecOneContext(c context.Context, query interface{}, params ...interface{}) (orm.Result, error) { 83 | return nil, nil 84 | } 85 | 86 | func (p *goPgDB) Query(model, query interface{}, params ...interface{}) (orm.Result, error) { 87 | sqlQuery := fmt.Sprintf("%v", query) 88 | return p.doQuery(context.Background(), model, sqlQuery, params...) 89 | } 90 | 91 | func (p *goPgDB) QueryContext(c context.Context, model, query interface{}, params ...interface{}) (orm.Result, error) { 92 | sqlQuery := fmt.Sprintf("%v", query) 93 | return p.doQuery(c, model, sqlQuery, params...) 94 | } 95 | 96 | func (p *goPgDB) QueryOne(model, query interface{}, params ...interface{}) (orm.Result, error) { 97 | sqlQuery := fmt.Sprintf("%v", query) 98 | return p.doQuery(context.Background(), model, sqlQuery, params...) 99 | } 100 | 101 | func (p *goPgDB) QueryOneContext(c context.Context, model, query interface{}, params ...interface{}) (orm.Result, error) { 102 | return nil, nil 103 | } 104 | 105 | func (p *goPgDB) CopyFrom(r io.Reader, query interface{}, params ...interface{}) (orm.Result, error) { 106 | return nil, nil 107 | } 108 | 109 | func (p *goPgDB) CopyTo(w io.Writer, query interface{}, params ...interface{}) (orm.Result, error) { 110 | return nil, nil 111 | } 112 | 113 | func (p *goPgDB) Context() context.Context { 114 | return context.Background() 115 | } 116 | 117 | func (p *goPgDB) Formatter() orm.QueryFormatter { 118 | f := new(Formatter) 119 | return f 120 | } 121 | 122 | func (p *goPgDB) doInsert(ctx context.Context, models ...interface{}) error { 123 | // update p.insertMock 124 | for k, v := range p.sqlMock.inserts { 125 | 126 | // not handling value at the moment 127 | 128 | onTheListInsertStr := k 129 | 130 | var inserts []string 131 | for _, v := range models { 132 | inserts = append(inserts, strings.ToLower(manager.GetType(v))) 133 | } 134 | wantedInsertStr := strings.Join(inserts, ",") 135 | 136 | if onTheListInsertStr == wantedInsertStr { 137 | return v.err 138 | } 139 | } 140 | 141 | return nil 142 | } 143 | 144 | func (p *goPgDB) doQuery(ctx context.Context, dst interface{}, query string, params ...interface{}) (orm.Result, error) { 145 | // replace duplicate space 146 | space := regexp.MustCompile(`\s+`) 147 | 148 | for k, v := range p.sqlMock.queries { 149 | onTheList := p.Formatter().FormatQuery(nil, k, v.params...) 150 | onTheListQueryStr := strings.TrimSpace(space.ReplaceAllString(string(onTheList), " ")) 151 | 152 | wantedQuery := p.Formatter().FormatQuery(nil, query, params...) 153 | wantedQueryStr := strings.TrimSpace(space.ReplaceAllString(string(wantedQuery), " ")) 154 | 155 | if onTheListQueryStr == wantedQueryStr { 156 | var ( 157 | data []byte 158 | err error 159 | ) 160 | 161 | if dst == nil { 162 | return v.result, v.err 163 | } 164 | 165 | data, err = json.Marshal(v.result.model) 166 | if err != nil { 167 | return v.result, err 168 | } 169 | 170 | err = json.Unmarshal(data, dst) 171 | if err != nil { 172 | return v.result, err 173 | } 174 | 175 | return v.result, v.err 176 | } 177 | } 178 | 179 | return nil, fmt.Errorf("no mock expectation result") 180 | } 181 | -------------------------------------------------------------------------------- /mockgopg/row_result.go: -------------------------------------------------------------------------------- 1 | package mockgopg 2 | 3 | import "github.com/go-pg/pg/v9/orm" 4 | 5 | // OrmResult struct to implements orm.Result 6 | type OrmResult struct { 7 | rowsAffected int 8 | rowsReturned int 9 | model interface{} 10 | } 11 | 12 | // Model implements an orm.Model 13 | func (o *OrmResult) Model() orm.Model { 14 | if o.model == nil { 15 | return nil 16 | } 17 | 18 | model, err := orm.NewModel(o.model) 19 | if err != nil { 20 | return nil 21 | } 22 | 23 | return model 24 | } 25 | 26 | // RowsAffected returns the number of rows affected in the data table 27 | func (o *OrmResult) RowsAffected() int { 28 | return o.rowsAffected 29 | } 30 | 31 | // RowsReturned returns the number of rows 32 | func (o *OrmResult) RowsReturned() int { 33 | return o.rowsReturned 34 | } 35 | 36 | // NewResult implements orm.Result in go-pg package 37 | func NewResult(rowAffected, rowReturned int, model interface{}) *OrmResult { 38 | return &OrmResult{ 39 | rowsAffected: rowAffected, 40 | rowsReturned: rowReturned, 41 | model: model, 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /model/auth.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "github.com/gin-gonic/gin" 4 | 5 | // AuthToken holds authentication token details with refresh token 6 | type AuthToken struct { 7 | Token string `json:"token"` 8 | Expires string `json:"expires"` 9 | RefreshToken string `json:"refresh_token"` 10 | } 11 | 12 | // RefreshToken holds authentication token details 13 | type RefreshToken struct { 14 | Token string `json:"token"` 15 | Expires string `json:"expires"` 16 | } 17 | 18 | // AuthService represents authentication service interface 19 | type AuthService interface { 20 | User(*gin.Context) *AuthUser 21 | } 22 | 23 | // RBACService represents role-based access control service interface 24 | type RBACService interface { 25 | EnforceRole(*gin.Context, AccessRole) bool 26 | EnforceUser(*gin.Context, int) bool 27 | EnforceCompany(*gin.Context, int) bool 28 | EnforceLocation(*gin.Context, int) bool 29 | AccountCreate(*gin.Context, int, int, int) bool 30 | IsLowerRole(*gin.Context, AccessRole) bool 31 | } 32 | -------------------------------------------------------------------------------- /model/company.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | func init() { 4 | Register(&Company{}) 5 | } 6 | 7 | // Company represents company model 8 | type Company struct { 9 | Base 10 | Name string `json:"name"` 11 | Active bool `json:"active"` 12 | Locations []Location `json:"locations,omitempty"` 13 | Owner User `json:"owner"` 14 | } 15 | -------------------------------------------------------------------------------- /model/location.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | func init() { 4 | Register(&Location{}) 5 | } 6 | 7 | // Location represents company location model 8 | type Location struct { 9 | Base 10 | ID int `json:"id"` 11 | Name string `json:"name"` 12 | Active bool `json:"active"` 13 | Address string `json:"address"` 14 | CompanyID int `json:"company_id"` 15 | } 16 | -------------------------------------------------------------------------------- /model/model.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "context" 5 | "time" 6 | ) 7 | 8 | // Models hold registered models in-memory 9 | var Models []interface{} 10 | 11 | // Base contains common fields for all tables 12 | type Base struct { 13 | CreatedAt time.Time `json:"created_at"` 14 | UpdatedAt time.Time `json:"updated_at"` 15 | DeletedAt *time.Time `json:"deleted_at,omitempty"` 16 | } 17 | 18 | // Pagination holds pagination's data 19 | type Pagination struct { 20 | Limit int 21 | Offset int 22 | } 23 | 24 | // ListQuery holds company/location data used for list db queries 25 | type ListQuery struct { 26 | Query string 27 | ID int 28 | } 29 | 30 | // BeforeInsert hooks into insert operations, setting createdAt and updatedAt to current time 31 | func (b *Base) BeforeInsert(ctx context.Context) (context.Context, error) { 32 | now := time.Now() 33 | if b.CreatedAt.IsZero() { 34 | b.CreatedAt = now 35 | } 36 | if b.UpdatedAt.IsZero() { 37 | b.UpdatedAt = now 38 | } 39 | return ctx, nil 40 | } 41 | 42 | // BeforeUpdate hooks into update operations, setting updatedAt to current time 43 | func (b *Base) BeforeUpdate(ctx context.Context) (context.Context, error) { 44 | b.UpdatedAt = time.Now() 45 | return ctx, nil 46 | } 47 | 48 | // Delete sets deleted_at time to current_time 49 | func (b *Base) Delete() { 50 | t := time.Now() 51 | b.DeletedAt = &t 52 | } 53 | 54 | // Register is used for registering models 55 | func Register(m interface{}) { 56 | Models = append(Models, m) 57 | } 58 | -------------------------------------------------------------------------------- /model/model_test.go: -------------------------------------------------------------------------------- 1 | package model_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/gogjango/gjango/mock" 7 | "github.com/gogjango/gjango/model" 8 | ) 9 | 10 | func TestBeforeInsert(t *testing.T) { 11 | base := &model.Base{} 12 | base.BeforeInsert(nil) 13 | if base.CreatedAt.IsZero() { 14 | t.Errorf("CreatedAt was not changed") 15 | } 16 | if base.UpdatedAt.IsZero() { 17 | t.Errorf("UpdatedAt was not changed") 18 | } 19 | } 20 | 21 | func TestBeforeUpdate(t *testing.T) { 22 | base := &model.Base{ 23 | CreatedAt: mock.TestTime(2000), 24 | } 25 | base.BeforeUpdate(nil) 26 | if base.UpdatedAt == mock.TestTime(2001) { 27 | t.Errorf("UpdatedAt was not changed") 28 | } 29 | 30 | } 31 | 32 | func TestDelete(t *testing.T) { 33 | baseModel := &model.Base{ 34 | CreatedAt: mock.TestTime(2000), 35 | UpdatedAt: mock.TestTime(2001), 36 | } 37 | baseModel.Delete() 38 | if baseModel.DeletedAt.IsZero() { 39 | t.Errorf("DeletedAt not changed") 40 | } 41 | 42 | } 43 | -------------------------------------------------------------------------------- /model/role.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | func init() { 4 | Register(&Role{}) 5 | } 6 | 7 | // AccessRole represents access role type 8 | type AccessRole int8 9 | 10 | const ( 11 | // SuperAdminRole has all permissions 12 | SuperAdminRole AccessRole = iota + 1 13 | 14 | // AdminRole has admin specific permissions 15 | AdminRole 16 | 17 | // CompanyAdminRole can edit company specific things 18 | CompanyAdminRole 19 | 20 | // LocationAdminRole can edit location specific things 21 | LocationAdminRole 22 | 23 | // UserRole is a standard user 24 | UserRole 25 | ) 26 | 27 | // Role model 28 | type Role struct { 29 | ID int `json:"id"` 30 | AccessLevel AccessRole `json:"access_level"` 31 | Name string `json:"name"` 32 | } 33 | 34 | // RoleRepo represents the database interface 35 | type RoleRepo interface { 36 | CreateRoles() error 37 | } 38 | -------------------------------------------------------------------------------- /model/user.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | func init() { 8 | Register(&User{}) 9 | } 10 | 11 | // User represents user domain model 12 | type User struct { 13 | Base 14 | ID int `json:"id"` 15 | FirstName string `json:"first_name"` 16 | LastName string `json:"last_name"` 17 | Username string `json:"username"` 18 | Password string `json:"-"` 19 | Email string `json:"email"` 20 | Mobile string `json:"mobile,omitempty"` 21 | CountryCode string `json:"country_code,omitempty"` 22 | Address string `json:"address,omitempty"` 23 | LastLogin *time.Time `json:"last_login,omitempty"` 24 | Verified bool `json:"verified"` 25 | Active bool `json:"active"` 26 | Token string `json:"-"` 27 | Role *Role `json:"role,omitempty"` 28 | RoleID int `json:"-"` 29 | CompanyID int `json:"company_id"` 30 | LocationID int `json:"location_id"` 31 | } 32 | 33 | // UpdateLastLogin updates last login field 34 | func (u *User) UpdateLastLogin() { 35 | t := time.Now() 36 | u.LastLogin = &t 37 | } 38 | 39 | // Delete updates the deleted_at field 40 | func (u *User) Delete() { 41 | t := time.Now() 42 | u.DeletedAt = &t 43 | } 44 | 45 | // Update updates the updated_at field 46 | func (u *User) Update() { 47 | t := time.Now() 48 | u.UpdatedAt = t 49 | } 50 | 51 | // UserRepo represents user database interface (the repository) 52 | type UserRepo interface { 53 | View(int) (*User, error) 54 | FindByUsername(string) (*User, error) 55 | FindByEmail(string) (*User, error) 56 | FindByMobile(string, string) (*User, error) 57 | FindByToken(string) (*User, error) 58 | UpdateLogin(*User) error 59 | List(*ListQuery, *Pagination) ([]User, error) 60 | Update(*User) (*User, error) 61 | Delete(*User) error 62 | } 63 | 64 | // AccountRepo represents account database interface (the repository) 65 | type AccountRepo interface { 66 | Create(*User) (*User, error) 67 | CreateAndVerify(*User) (*Verification, error) 68 | CreateWithMobile(*User) error 69 | ChangePassword(*User) error 70 | FindVerificationToken(string) (*Verification, error) 71 | DeleteVerificationToken(*Verification) error 72 | } 73 | 74 | // AuthUser represents data stored in JWT token for user 75 | type AuthUser struct { 76 | ID int 77 | CompanyID int 78 | LocationID int 79 | Username string 80 | Email string 81 | Role AccessRole 82 | } 83 | -------------------------------------------------------------------------------- /model/user_test.go: -------------------------------------------------------------------------------- 1 | package model_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/gogjango/gjango/model" 7 | ) 8 | 9 | func TestUpdateLastLogin(t *testing.T) { 10 | user := &model.User{ 11 | FirstName: "TestGuy", 12 | } 13 | user.UpdateLastLogin() 14 | if user.LastLogin.IsZero() { 15 | t.Errorf("Last login time was not changed") 16 | } 17 | } 18 | 19 | func TestUpdateUpdatedAt(t *testing.T) { 20 | user := &model.User{ 21 | FirstName: "TestGal", 22 | } 23 | user.Update() 24 | if user.UpdatedAt.IsZero() { 25 | t.Errorf("updated_at is not changed") 26 | } 27 | } 28 | 29 | func TestUpdateDeletedAt(t *testing.T) { 30 | user := &model.User{ 31 | FirstName: "TestGod", 32 | } 33 | user.Delete() 34 | if user.DeletedAt.IsZero() { 35 | t.Errorf("deleted_at is not changed") 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /model/verification.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | func init() { 4 | Register(&Verification{}) 5 | } 6 | 7 | // Verification stores randomly generated tokens that can be redeemed 8 | type Verification struct { 9 | Base 10 | ID int `json:"id"` 11 | Token string `json:"token"` 12 | UserID int `json:"user_id"` 13 | } 14 | -------------------------------------------------------------------------------- /repository/account.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/go-pg/pg/v9/orm" 7 | "github.com/gogjango/gjango/apperr" 8 | "github.com/gogjango/gjango/model" 9 | "github.com/gogjango/gjango/secret" 10 | uuid "github.com/satori/go.uuid" 11 | "go.uber.org/zap" 12 | ) 13 | 14 | // NewAccountRepo returns an AccountRepo instance 15 | func NewAccountRepo(db orm.DB, log *zap.Logger, secret secret.Service) *AccountRepo { 16 | return &AccountRepo{db, log, secret} 17 | } 18 | 19 | // AccountRepo represents the client for the user table 20 | type AccountRepo struct { 21 | db orm.DB 22 | log *zap.Logger 23 | Secret secret.Service 24 | } 25 | 26 | // Create creates a new user in our database 27 | func (a *AccountRepo) Create(u *model.User) (*model.User, error) { 28 | user := new(model.User) 29 | sql := `SELECT id FROM users WHERE username = ? OR email = ? OR (country_code = ? AND mobile = ?) AND deleted_at IS NULL` 30 | res, err := a.db.Query(user, sql, u.Username, u.Email, u.CountryCode, u.Mobile) 31 | if err != nil { 32 | a.log.Error("AccountRepo Error: ", zap.Error(err)) 33 | return nil, apperr.DB 34 | } 35 | if res.RowsReturned() != 0 { 36 | return nil, apperr.New(http.StatusBadRequest, "User already exists.") 37 | } 38 | if err := a.db.Insert(u); err != nil { 39 | a.log.Warn("AccountRepo error: ", zap.Error(err)) 40 | return nil, apperr.DB 41 | } 42 | return u, nil 43 | } 44 | 45 | // CreateAndVerify creates a new user in our database, and generates a verification token. 46 | // User active being false until after verification. 47 | func (a *AccountRepo) CreateAndVerify(u *model.User) (*model.Verification, error) { 48 | user := new(model.User) 49 | sql := `SELECT id FROM users WHERE username = ? OR email = ? OR (country_code = ? AND mobile = ?) AND deleted_at IS NULL` 50 | res, err := a.db.Query(user, sql, u.Username, u.Email, u.CountryCode, u.Mobile) 51 | if err == apperr.DB { 52 | a.log.Error("AccountRepo Error: ", zap.Error(err)) 53 | return nil, apperr.DB 54 | } 55 | if res.RowsReturned() != 0 { 56 | return nil, apperr.New(http.StatusBadRequest, "User already exists.") 57 | } 58 | if err := a.db.Insert(u); err != nil { 59 | a.log.Warn("AccountRepo error: ", zap.Error(err)) 60 | return nil, apperr.DB 61 | } 62 | v := new(model.Verification) 63 | v.UserID = u.ID 64 | v.Token = uuid.NewV4().String() 65 | if err := a.db.Insert(v); err != nil { 66 | a.log.Warn("AccountRepo error: ", zap.Error(err)) 67 | return nil, apperr.DB 68 | } 69 | return v, nil 70 | } 71 | 72 | // CreateWithMobile creates a new user in our database with country code and mobile number 73 | func (a *AccountRepo) CreateWithMobile(u *model.User) error { 74 | user := new(model.User) 75 | sql := `SELECT id FROM users WHERE username = ? OR email = ? OR (country_code = ? AND mobile = ?) AND deleted_at IS NULL` 76 | res, err := a.db.Query(user, sql, u.Username, u.Email, u.CountryCode, u.Mobile) 77 | if err == apperr.DB { 78 | a.log.Error("AccountRepo Error: ", zap.Error(err)) 79 | return apperr.DB 80 | } 81 | if res.RowsReturned() != 0 && user.Verified == true { 82 | return apperr.NewStatus(http.StatusConflict) // user already exists and is already verified 83 | } 84 | if res.RowsReturned() != 0 { 85 | return apperr.BadRequest // user already exists but is not yet verified 86 | } 87 | // generate a cryptographically secure random password hash for this user 88 | u.Password, err = a.Secret.HashRandomPassword() 89 | if err != nil { 90 | return apperr.DB 91 | } 92 | if err := a.db.Insert(u); err != nil { 93 | a.log.Warn("AccountRepo error: ", zap.Error(err)) 94 | return apperr.DB 95 | } 96 | return nil 97 | } 98 | 99 | // ChangePassword changes user's password 100 | func (a *AccountRepo) ChangePassword(u *model.User) error { 101 | u.Update() 102 | _, err := a.db.Model(u).Column("password", "updated_at").WherePK().Update() 103 | if err != nil { 104 | a.log.Warn("AccountRepo Error: ", zap.Error(err)) 105 | } 106 | return err 107 | } 108 | 109 | // FindVerificationToken retrieves an existing verification token 110 | func (a *AccountRepo) FindVerificationToken(token string) (*model.Verification, error) { 111 | var v = new(model.Verification) 112 | sql := `SELECT * FROM verifications WHERE (token = ? and deleted_at IS NULL)` 113 | _, err := a.db.QueryOne(v, sql, token) 114 | if err != nil { 115 | a.log.Warn("AccountRepo Error", zap.String("Error:", err.Error())) 116 | return nil, apperr.NotFound 117 | } 118 | return v, nil 119 | } 120 | 121 | // DeleteVerificationToken sets deleted_at for an existing verification token 122 | func (a *AccountRepo) DeleteVerificationToken(v *model.Verification) error { 123 | v.Delete() 124 | _, err := a.db.Model(v).Column("deleted_at").WherePK().Update() 125 | if err != nil { 126 | a.log.Warn("AccountRepo Error", zap.Error(err)) 127 | return apperr.DB 128 | } 129 | return err 130 | } 131 | -------------------------------------------------------------------------------- /repository/account/account.go: -------------------------------------------------------------------------------- 1 | package account 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | "github.com/gogjango/gjango/apperr" 8 | "github.com/gogjango/gjango/model" 9 | "github.com/gogjango/gjango/secret" 10 | ) 11 | 12 | // Service represents the account application service 13 | type Service struct { 14 | accountRepo model.AccountRepo 15 | userRepo model.UserRepo 16 | rbac model.RBACService 17 | secret secret.Service 18 | } 19 | 20 | // NewAccountService creates a new account application service 21 | func NewAccountService(userRepo model.UserRepo, accountRepo model.AccountRepo, rbac model.RBACService, secret secret.Service) *Service { 22 | return &Service{ 23 | accountRepo: accountRepo, 24 | userRepo: userRepo, 25 | rbac: rbac, 26 | secret: secret, 27 | } 28 | } 29 | 30 | // Create creates a new user account 31 | func (s *Service) Create(c *gin.Context, u *model.User) error { 32 | if !s.rbac.AccountCreate(c, u.RoleID, u.CompanyID, u.LocationID) { 33 | return apperr.Forbidden 34 | } 35 | u.Password = s.secret.HashPassword(u.Password) 36 | u, err := s.accountRepo.Create(u) 37 | return err 38 | } 39 | 40 | // ChangePassword changes user's password 41 | func (s *Service) ChangePassword(c *gin.Context, oldPass, newPass string, id int) error { 42 | if !s.rbac.EnforceUser(c, id) { 43 | return apperr.Forbidden 44 | } 45 | u, err := s.userRepo.View(id) 46 | if err != nil { 47 | return err 48 | } 49 | if !s.secret.HashMatchesPassword(u.Password, oldPass) { 50 | return apperr.New(http.StatusBadGateway, "old password is not correct") 51 | } 52 | u.Password = s.secret.HashPassword(newPass) 53 | return s.accountRepo.ChangePassword(u) 54 | } 55 | -------------------------------------------------------------------------------- /repository/account_i_test.go: -------------------------------------------------------------------------------- 1 | package repository_test 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | "os" 8 | "path" 9 | "path/filepath" 10 | "runtime" 11 | "testing" 12 | 13 | embeddedpostgres "github.com/fergusstrange/embedded-postgres" 14 | "github.com/go-pg/pg/v9" 15 | "github.com/go-pg/pg/v9/orm" 16 | "github.com/gogjango/gjango/apperr" 17 | "github.com/gogjango/gjango/model" 18 | "github.com/gogjango/gjango/repository" 19 | "github.com/gogjango/gjango/secret" 20 | uuid "github.com/satori/go.uuid" 21 | "github.com/stretchr/testify/assert" 22 | "github.com/stretchr/testify/suite" 23 | "go.uber.org/zap" 24 | ) 25 | 26 | type AccountTestSuite struct { 27 | suite.Suite 28 | db *pg.DB 29 | dbErr *pg.DB 30 | postgres *embeddedpostgres.EmbeddedPostgres 31 | } 32 | 33 | func (suite *AccountTestSuite) SetupTest() { 34 | _, b, _, _ := runtime.Caller(0) 35 | d := path.Join(path.Dir(b)) 36 | projectRoot := filepath.Dir(d) 37 | tmpDir := path.Join(projectRoot, "tmp") 38 | os.RemoveAll(tmpDir) 39 | testConfig := embeddedpostgres.DefaultConfig(). 40 | Username("db_test_user"). 41 | Password("db_test_password"). 42 | Database("db_test_database"). 43 | Version(embeddedpostgres.V12). 44 | RuntimePath(tmpDir). 45 | Port(9876) 46 | 47 | suite.postgres = embeddedpostgres.NewDatabase(testConfig) 48 | err := suite.postgres.Start() 49 | assert.Equal(suite.T(), err, nil) 50 | 51 | suite.db = pg.Connect(&pg.Options{ 52 | Addr: "localhost:9876", 53 | User: "db_test_user", 54 | Password: "db_test_password", 55 | Database: "db_test_database", 56 | }) 57 | suite.dbErr = pg.Connect(&pg.Options{ 58 | Addr: "localhost:9875", 59 | User: "db_test_user", 60 | Password: "db_test_password", 61 | Database: "db_test_database", 62 | }) 63 | createSchema(suite.db, &model.Company{}, &model.Location{}, &model.Role{}, &model.User{}, &model.Verification{}) 64 | } 65 | 66 | func (suite *AccountTestSuite) TearDownTest() { 67 | suite.postgres.Stop() 68 | } 69 | 70 | func (suite *AccountTestSuite) TestAccountCreateWithMobile() { 71 | cases := []struct { 72 | name string 73 | user *model.User 74 | db *pg.DB 75 | wantError error 76 | wantResult *model.Verification 77 | }{ 78 | { 79 | name: "Success: user created", 80 | user: &model.User{ 81 | CountryCode: "+65", 82 | Mobile: "91919191", 83 | }, 84 | db: suite.db, 85 | wantError: nil, 86 | }, 87 | } 88 | for _, tt := range cases { 89 | suite.T().Run(tt.name, func(t *testing.T) { 90 | log, _ := zap.NewDevelopment() 91 | accountRepo := repository.NewAccountRepo(tt.db, log, secret.New()) 92 | err := accountRepo.CreateWithMobile(tt.user) 93 | assert.Equal(t, tt.wantError, err) 94 | }) 95 | } 96 | } 97 | 98 | func (suite *AccountTestSuite) TestAccountCreateAndVerify() { 99 | cases := []struct { 100 | name string 101 | user *model.User 102 | db *pg.DB 103 | wantError error 104 | wantResult *model.Verification 105 | }{ 106 | { 107 | name: "Success: user created", 108 | user: &model.User{ 109 | CountryCode: "+65", 110 | Mobile: "91919191", 111 | }, 112 | db: suite.db, 113 | wantError: nil, 114 | }, 115 | { 116 | name: "Failure: user already exists", 117 | user: &model.User{ 118 | CountryCode: "+65", 119 | Mobile: "91919191", 120 | }, 121 | db: suite.db, 122 | wantError: apperr.New(http.StatusBadRequest, "User already exists."), 123 | wantResult: nil, 124 | }, 125 | } 126 | 127 | for _, tt := range cases { 128 | suite.T().Run(tt.name, func(t *testing.T) { 129 | log, _ := zap.NewDevelopment() 130 | accountRepo := repository.NewAccountRepo(tt.db, log, secret.New()) 131 | v, err := accountRepo.CreateAndVerify(tt.user) 132 | assert.Equal(t, tt.wantError, err) 133 | if v != nil { 134 | fmt.Println(v.UserID) 135 | fmt.Println(v.Token) 136 | } 137 | }) 138 | } 139 | } 140 | 141 | func (suite *AccountTestSuite) TestAccountCreate() { 142 | cases := []struct { 143 | name string 144 | user *model.User 145 | db *pg.DB 146 | wantError error 147 | wantResult *model.User 148 | }{ 149 | { 150 | name: "Success: user created", 151 | user: &model.User{ 152 | Email: "user@example.org", 153 | }, 154 | db: suite.db, 155 | wantError: nil, 156 | wantResult: &model.User{ 157 | Email: "user@example.org", 158 | }, 159 | }, 160 | { 161 | name: "Failure: user already exists", 162 | user: &model.User{ 163 | Email: "user@example.org", 164 | }, 165 | db: suite.db, 166 | wantError: apperr.New(http.StatusBadRequest, "User already exists."), 167 | wantResult: nil, 168 | }, 169 | { 170 | name: "Failure: db connection failed", 171 | db: suite.dbErr, 172 | user: &model.User{ 173 | Email: "user2@example.org", 174 | }, 175 | wantError: apperr.DB, 176 | wantResult: nil, 177 | }, 178 | { 179 | name: "Failure", 180 | db: suite.db, 181 | user: &model.User{ 182 | ID: 1, 183 | Email: "user2@example.org", 184 | }, 185 | wantError: apperr.DB, 186 | wantResult: nil, 187 | }, 188 | } 189 | 190 | for _, tt := range cases { 191 | suite.T().Run(tt.name, func(t *testing.T) { 192 | log, _ := zap.NewDevelopment() 193 | accountRepo := repository.NewAccountRepo(tt.db, log, secret.New()) 194 | u, err := accountRepo.Create(tt.user) 195 | assert.Equal(t, tt.wantError, err) 196 | if u != nil { 197 | assert.Equal(t, tt.wantResult.Email, u.Email) 198 | } else { 199 | assert.Nil(t, u) 200 | } 201 | }) 202 | } 203 | } 204 | 205 | func (suite *AccountTestSuite) TestChangePasswordSuccess() { 206 | log, _ := zap.NewDevelopment() 207 | accountRepo := repository.NewAccountRepo(suite.db, log, secret.New()) 208 | userRepo := repository.NewUserRepo(suite.db, log) 209 | currentPassword := secret.New().HashPassword("currentpassword") 210 | user := &model.User{ 211 | Email: "user3@example.org", 212 | Password: currentPassword, 213 | } 214 | u, err := accountRepo.Create(user) 215 | assert.Equal(suite.T(), user.Password, u.Password) 216 | assert.NotNil(suite.T(), u.Password) 217 | assert.Nil(suite.T(), err) 218 | 219 | newPassword := secret.New().HashPassword("newpassword") 220 | u.Password = newPassword 221 | assert.NotEqual(suite.T(), currentPassword, newPassword) 222 | err = accountRepo.ChangePassword(u) 223 | assert.Nil(suite.T(), err) 224 | 225 | updatedUser, err := userRepo.View(u.ID) 226 | assert.Nil(suite.T(), err) 227 | assert.Equal(suite.T(), newPassword, updatedUser.Password) 228 | 229 | user2 := &model.User{ 230 | Email: "user4@example.org", 231 | Password: currentPassword, 232 | } 233 | v, err := accountRepo.CreateAndVerify(user2) 234 | assert.Nil(suite.T(), err) 235 | assert.NotNil(suite.T(), v) 236 | 237 | vRetrieved, err := accountRepo.FindVerificationToken(v.Token) 238 | assert.Nil(suite.T(), err) 239 | assert.Equal(suite.T(), v.Token, vRetrieved.Token) 240 | 241 | err = accountRepo.DeleteVerificationToken(v) 242 | assert.Nil(suite.T(), err) 243 | } 244 | 245 | func (suite *AccountTestSuite) TestChangePasswordFailure() { 246 | log, _ := zap.NewDevelopment() 247 | defer log.Sync() 248 | accountRepo := repository.NewAccountRepo(suite.dbErr, log, secret.New()) 249 | user := &model.User{ 250 | Email: "user5@example.org", 251 | Password: secret.New().HashPassword("somepass"), 252 | } 253 | err := accountRepo.ChangePassword(user) 254 | assert.NotNil(suite.T(), err) 255 | } 256 | 257 | func (suite *AccountTestSuite) TestDeleteVerificationTokenFailue() { 258 | log, _ := zap.NewDevelopment() 259 | defer log.Sync() 260 | accountRepo := repository.NewAccountRepo(suite.dbErr, log, secret.New()) 261 | v := &model.Verification{ 262 | UserID: 1, 263 | Token: uuid.NewV4().String(), 264 | } 265 | err := accountRepo.DeleteVerificationToken(v) 266 | assert.NotNil(suite.T(), err) 267 | } 268 | 269 | func TestAccountTestSuiteIntegration(t *testing.T) { 270 | if testing.Short() { 271 | t.Skip("skipping integration test") 272 | return 273 | } 274 | suite.Run(t, new(AccountTestSuite)) 275 | } 276 | 277 | func createSchema(db *pg.DB, models ...interface{}) { 278 | for _, model := range models { 279 | opt := &orm.CreateTableOptions{ 280 | IfNotExists: true, 281 | FKConstraints: true, 282 | } 283 | err := db.CreateTable(model, opt) 284 | if err != nil { 285 | log.Fatal(err) 286 | } 287 | } 288 | } 289 | -------------------------------------------------------------------------------- /repository/account_test.go: -------------------------------------------------------------------------------- 1 | package repository_test 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | 7 | "github.com/go-pg/pg/v9/orm" 8 | "github.com/gogjango/gjango/apperr" 9 | "github.com/gogjango/gjango/mock" 10 | mck "github.com/gogjango/gjango/mock" 11 | "github.com/gogjango/gjango/mockgopg" 12 | "github.com/gogjango/gjango/model" 13 | "github.com/gogjango/gjango/repository" 14 | "github.com/stretchr/testify/assert" 15 | "github.com/stretchr/testify/suite" 16 | "go.uber.org/zap" 17 | ) 18 | 19 | type AccountUnitTestSuite struct { 20 | suite.Suite 21 | mock *mockgopg.SQLMock 22 | u *model.User 23 | accountRepo *repository.AccountRepo 24 | } 25 | 26 | func (suite *AccountUnitTestSuite) SetupTest() { 27 | var err error 28 | var db orm.DB 29 | db, suite.mock, err = mockgopg.NewGoPGDBTest() 30 | if err != nil { 31 | suite.T().Fatalf("an error '%s' was not expected when opening a stub database connection", err) 32 | } 33 | suite.u = &model.User{ 34 | Username: "hello", 35 | Email: "hello@world.org", 36 | CountryCode: "+65", 37 | Mobile: "91919191", 38 | } 39 | 40 | log, _ := zap.NewDevelopment() 41 | suite.accountRepo = repository.NewAccountRepo(db, log, &mock.Password{}) 42 | } 43 | 44 | func (suite *AccountUnitTestSuite) TearDownTest() { 45 | suite.mock.FlushAll() 46 | } 47 | 48 | func TestAccountUnitTestSuite(t *testing.T) { 49 | suite.Run(t, new(AccountUnitTestSuite)) 50 | } 51 | 52 | // Mock database error when querying 53 | func (suite *AccountUnitTestSuite) TestCreateAndVerifyDBError() { 54 | u := suite.u 55 | accountRepo := suite.accountRepo 56 | t := suite.T() 57 | mock := suite.mock 58 | 59 | mock.ExpectQuery(`SELECT id FROM users WHERE username = ? OR email = ? OR (country_code = ? AND mobile = ?) AND deleted_at IS NULL`). 60 | WithArgs(u.Username, u.Email, u.CountryCode, u.Mobile). 61 | Returns(mockgopg.NewResult(0, 0, nil), apperr.DB) 62 | v, err := accountRepo.CreateAndVerify(u) 63 | assert.Nil(t, v) 64 | assert.Equal(t, apperr.DB, err) 65 | } 66 | 67 | // Mock user already exists 68 | func (suite *AccountUnitTestSuite) TestCreateAndVerifyUserAlreadyExists() { 69 | u := suite.u 70 | accountRepo := suite.accountRepo 71 | t := suite.T() 72 | mock := suite.mock 73 | 74 | mock.ExpectQuery(`SELECT id FROM users WHERE username = ? OR email = ? OR (country_code = ? AND mobile = ?) AND deleted_at IS NULL`). 75 | WithArgs(u.Username, u.Email, u.CountryCode, u.Mobile). 76 | Returns(mockgopg.NewResult(1, 1, u), nil) 77 | v, err := accountRepo.CreateAndVerify(u) 78 | assert.Nil(t, v) 79 | assert.Equal(t, apperr.New(http.StatusBadRequest, "User already exists."), err) 80 | } 81 | 82 | // Mock DB error when inserting user object 83 | func (suite *AccountUnitTestSuite) TestCreateAndVerifyDBErrOnInsertUser() { 84 | u := suite.u 85 | accountRepo := suite.accountRepo 86 | t := suite.T() 87 | mock := suite.mock 88 | 89 | mock.ExpectQuery(`SELECT id FROM users WHERE username = ? OR email = ? OR (country_code = ? AND mobile = ?) AND deleted_at IS NULL`). 90 | WithArgs(u.Username, u.Email, u.CountryCode, u.Mobile). 91 | Returns(mockgopg.NewResult(0, 0, nil), apperr.NotFound) 92 | 93 | mock.ExpectInsert(u). 94 | Returns(nil, apperr.DB) 95 | 96 | v, err := accountRepo.CreateAndVerify(u) 97 | assert.Nil(t, v) 98 | assert.Equal(t, apperr.DB, err) 99 | } 100 | 101 | // Mock DB error when inserting verification object 102 | func (suite *AccountUnitTestSuite) TestCreateAndVerifyDBErrOnInsertVerification() { 103 | u := suite.u 104 | accountRepo := suite.accountRepo 105 | t := suite.T() 106 | mock := suite.mock 107 | 108 | mock.ExpectQuery(`SELECT id FROM users WHERE username = ? OR email = ? OR (country_code = ? AND mobile = ?) AND deleted_at IS NULL`). 109 | WithArgs(u.Username, u.Email, u.CountryCode, u.Mobile). 110 | Returns(mockgopg.NewResult(0, 0, nil), apperr.NotFound) 111 | 112 | mock.ExpectInsert(u). 113 | Returns(nil, nil) 114 | 115 | v := new(model.Verification) 116 | mock.ExpectInsert(v). 117 | Returns(nil, apperr.DB) 118 | 119 | v, err := accountRepo.CreateAndVerify(u) 120 | assert.Nil(t, v) 121 | assert.Equal(t, apperr.DB, err) 122 | } 123 | 124 | // Mock DB error when querying 125 | func (suite *AccountUnitTestSuite) TestCreateWithMobileDBErr() { 126 | u := suite.u 127 | accountRepo := suite.accountRepo 128 | t := suite.T() 129 | mock := suite.mock 130 | 131 | mock.ExpectQuery(`SELECT id FROM users WHERE username = ? OR email = ? OR (country_code = ? AND mobile = ?) AND deleted_at IS NULL`). 132 | WithArgs(u.Username, u.Email, u.CountryCode, u.Mobile). 133 | Returns(mockgopg.NewResult(0, 0, nil), apperr.DB) 134 | err := accountRepo.CreateWithMobile(u) 135 | assert.Equal(t, apperr.DB, err) 136 | } 137 | 138 | // Mock user exists and is already verified and active when queried 139 | func (suite *AccountUnitTestSuite) TestCreateWithMobileUserExistsAndVerified() { 140 | u := suite.u 141 | accountRepo := suite.accountRepo 142 | t := suite.T() 143 | mock := suite.mock 144 | 145 | u.Verified = true // set expecged user as verified 146 | u.Active = true // set expected user object as active 147 | mock.ExpectQuery(`SELECT id FROM users WHERE username = ? OR email = ? OR (country_code = ? AND mobile = ?) AND deleted_at IS NULL`). 148 | WithArgs(u.Username, u.Email, u.CountryCode, u.Mobile). 149 | Returns(mockgopg.NewResult(1, 1, u), nil) 150 | err := accountRepo.CreateWithMobile(u) 151 | assert.Equal(t, apperr.NewStatus(http.StatusConflict), err) 152 | } 153 | 154 | // Mock user exists but is not verified when queried 155 | func (suite *AccountUnitTestSuite) TestCreateWithMobileUserExistsButNotVerified() { 156 | u := suite.u 157 | accountRepo := suite.accountRepo 158 | t := suite.T() 159 | mock := suite.mock 160 | 161 | mock.ExpectQuery(`SELECT id FROM users WHERE username = ? OR email = ? OR (country_code = ? AND mobile = ?) AND deleted_at IS NULL`). 162 | WithArgs(u.Username, u.Email, u.CountryCode, u.Mobile). 163 | Returns(mockgopg.NewResult(1, 1, nil), nil) 164 | err := accountRepo.CreateWithMobile(u) 165 | assert.Equal(t, apperr.BadRequest, err) 166 | } 167 | 168 | // Mock HashRandomPassword error 169 | func (suite *AccountUnitTestSuite) TestCreateWithMobileHashRandomPasswordErr() { 170 | u := suite.u 171 | accountRepo := suite.accountRepo 172 | t := suite.T() 173 | mock := suite.mock 174 | 175 | mock.ExpectQuery(`SELECT id FROM users WHERE username = ? OR email = ? OR (country_code = ? AND mobile = ?) AND deleted_at IS NULL`). 176 | WithArgs(u.Username, u.Email, u.CountryCode, u.Mobile). 177 | Returns(mockgopg.NewResult(0, 0, nil), apperr.NotFound) 178 | 179 | // mock a HashRandomPassword error 180 | accountRepo.Secret = &mck.Password{ 181 | HashRandomPasswordFn: func() (string, error) { 182 | return "", apperr.DB 183 | }, 184 | } 185 | 186 | err := accountRepo.CreateWithMobile(u) 187 | assert.Equal(t, apperr.DB, err) 188 | } 189 | 190 | // Mock db error when insert 191 | func (suite *AccountUnitTestSuite) TestCreateWithMobileDBErrOnInsert() { 192 | u := suite.u 193 | accountRepo := suite.accountRepo 194 | t := suite.T() 195 | mock := suite.mock 196 | 197 | mock.ExpectQuery(`SELECT id FROM users WHERE username = ? OR email = ? OR (country_code = ? AND mobile = ?) AND deleted_at IS NULL`). 198 | WithArgs(u.Username, u.Email, u.CountryCode, u.Mobile). 199 | Returns(mockgopg.NewResult(0, 0, nil), apperr.NotFound) 200 | 201 | // mock a successful HashRandomPassword 202 | accountRepo.Secret = &mck.Password{ 203 | HashRandomPasswordFn: func() (string, error) { 204 | return "somerandomhash", nil 205 | }, 206 | } 207 | 208 | mock.ExpectInsert(u). 209 | Returns(nil, apperr.DB) 210 | 211 | err := accountRepo.CreateWithMobile(u) 212 | assert.Equal(t, apperr.DB, err) 213 | } 214 | 215 | func (suite *AccountUnitTestSuite) TestFindVerificationTokenSuccess() { 216 | accountRepo := suite.accountRepo 217 | t := suite.T() 218 | mock := suite.mock 219 | 220 | var v = new(model.Verification) 221 | v.Token = "somerandomverificationtoken" 222 | v.UserID = 1 223 | mock.ExpectQuery(`SELECT * FROM verifications WHERE (token = ? and deleted_at IS NULL)`). 224 | WithArgs("somerandomverificationtoken"). 225 | Returns(mockgopg.NewResult(1, 1, v), nil) 226 | 227 | vReturned, err := accountRepo.FindVerificationToken("somerandomverificationtoken") 228 | assert.Equal(t, v.Token, vReturned.Token) 229 | assert.Equal(t, v.UserID, vReturned.UserID) 230 | assert.Nil(t, err) 231 | } 232 | 233 | func (suite *AccountUnitTestSuite) TestFindVerificationTokenFailure() { 234 | accountRepo := suite.accountRepo 235 | t := suite.T() 236 | mock := suite.mock 237 | 238 | var v = new(model.Verification) 239 | v.Token = "anotherverificationtoken" 240 | v.UserID = 1 241 | mock.ExpectQuery(`SELECT * FROM verifications WHERE (token = ? and deleted_at IS NULL)`). 242 | WithArgs("anotherverificationtoken"). 243 | Returns(mockgopg.NewResult(0, 0, v), apperr.NotFound) 244 | 245 | vReturned, err := accountRepo.FindVerificationToken("anotherverificationtoken") 246 | assert.Nil(t, vReturned) 247 | assert.Equal(t, apperr.NotFound, err) 248 | } 249 | -------------------------------------------------------------------------------- /repository/auth/auth.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "github.com/gin-gonic/gin" 8 | "github.com/rs/xid" 9 | 10 | "github.com/gogjango/gjango/apperr" 11 | "github.com/gogjango/gjango/mail" 12 | "github.com/gogjango/gjango/mobile" 13 | "github.com/gogjango/gjango/model" 14 | "github.com/gogjango/gjango/request" 15 | "github.com/gogjango/gjango/secret" 16 | ) 17 | 18 | // NewAuthService creates new auth service 19 | func NewAuthService(userRepo model.UserRepo, accountRepo model.AccountRepo, jwt JWT, m mail.Service, mob mobile.Service) *Service { 20 | return &Service{userRepo, accountRepo, jwt, m, mob} 21 | } 22 | 23 | // Service represents the auth application service 24 | type Service struct { 25 | userRepo model.UserRepo 26 | accountRepo model.AccountRepo 27 | jwt JWT 28 | m mail.Service 29 | mob mobile.Service 30 | } 31 | 32 | // JWT represents jwt interface 33 | type JWT interface { 34 | GenerateToken(*model.User) (string, string, error) 35 | } 36 | 37 | // Authenticate tries to authenticate the user provided by username and password 38 | func (s *Service) Authenticate(c context.Context, email, password string) (*model.AuthToken, error) { 39 | u, err := s.userRepo.FindByEmail(email) 40 | if err != nil { 41 | return nil, apperr.Unauthorized 42 | } 43 | if !secret.New().HashMatchesPassword(u.Password, password) { 44 | return nil, apperr.Unauthorized 45 | } 46 | // user must be active and verified. Active is enabled/disabled by superadmin user. Verified depends on user verifying via /verification/:token or /mobile/verify 47 | if !u.Active || !u.Verified { 48 | return nil, apperr.Unauthorized 49 | } 50 | token, expire, err := s.jwt.GenerateToken(u) 51 | if err != nil { 52 | return nil, apperr.Unauthorized 53 | } 54 | u.UpdateLastLogin() 55 | u.Token = xid.New().String() 56 | if err := s.userRepo.UpdateLogin(u); err != nil { 57 | return nil, err 58 | } 59 | return &model.AuthToken{ 60 | Token: token, 61 | Expires: expire, 62 | RefreshToken: u.Token, 63 | }, nil 64 | } 65 | 66 | // Refresh refreshes jwt token and puts new claims inside 67 | func (s *Service) Refresh(c context.Context, refreshToken string) (*model.RefreshToken, error) { 68 | user, err := s.userRepo.FindByToken(refreshToken) 69 | if err != nil { 70 | return nil, err 71 | } 72 | // this is our re-generated JWT 73 | token, expire, err := s.jwt.GenerateToken(user) 74 | if err != nil { 75 | return nil, apperr.Generic 76 | } 77 | return &model.RefreshToken{ 78 | Token: token, 79 | Expires: expire, 80 | }, nil 81 | } 82 | 83 | // Verify verifies the (verification) token and deletes it 84 | func (s *Service) Verify(c context.Context, token string) error { 85 | v, err := s.accountRepo.FindVerificationToken(token) 86 | if err != nil { 87 | return err 88 | } 89 | err = s.accountRepo.DeleteVerificationToken(v) 90 | if err != nil { 91 | return err 92 | } 93 | return nil 94 | } 95 | 96 | // MobileVerify verifies the mobile verification code, i.e. (6-digit) code 97 | func (s *Service) MobileVerify(c context.Context, countryCode, mobile, code string, signup bool) (*model.AuthToken, error) { 98 | // send code to twilio 99 | err := s.mob.CheckCode(countryCode, mobile, code) 100 | if err != nil { 101 | return nil, err 102 | } 103 | u, err := s.userRepo.FindByMobile(countryCode, mobile) 104 | if err != nil { 105 | return nil, err 106 | } 107 | if signup { // signup case, make user verified and active 108 | u.Verified = true 109 | u.Active = true 110 | } else { // login case, update user's last_login attribute 111 | u.UpdateLastLogin() 112 | } 113 | u, err = s.userRepo.Update(u) 114 | if err != nil { 115 | return nil, err 116 | } 117 | 118 | // generate jwt and return 119 | token, expire, err := s.jwt.GenerateToken(u) 120 | if err != nil { 121 | return nil, apperr.Unauthorized 122 | } 123 | u.UpdateLastLogin() 124 | u.Token = xid.New().String() 125 | if err := s.userRepo.UpdateLogin(u); err != nil { 126 | return nil, err 127 | } 128 | return &model.AuthToken{ 129 | Token: token, 130 | Expires: expire, 131 | RefreshToken: u.Token, 132 | }, nil 133 | } 134 | 135 | // User returns user data stored in jwt token 136 | func (s *Service) User(c *gin.Context) *model.AuthUser { 137 | id := c.GetInt("id") 138 | companyID := c.GetInt("company_id") 139 | locationID := c.GetInt("location_id") 140 | user := c.GetString("username") 141 | email := c.GetString("email") 142 | role := c.MustGet("role").(int8) 143 | return &model.AuthUser{ 144 | ID: id, 145 | Username: user, 146 | CompanyID: companyID, 147 | LocationID: locationID, 148 | Email: email, 149 | Role: model.AccessRole(role), 150 | } 151 | } 152 | 153 | // Signup returns any error from creating a new user in our database 154 | func (s *Service) Signup(c *gin.Context, e *request.EmailSignup) error { 155 | _, err := s.userRepo.FindByEmail(e.Email) 156 | if err == nil { // user already exists 157 | return apperr.NewStatus(http.StatusConflict) 158 | } 159 | v, err := s.accountRepo.CreateAndVerify(&model.User{Email: e.Email, Password: e.Password}) 160 | if err != nil { 161 | return err 162 | } 163 | err = s.m.SendVerificationEmail(e.Email, v) 164 | if err != nil { 165 | apperr.Response(c, err) 166 | return err 167 | } 168 | return nil 169 | } 170 | 171 | // Mobile returns any error from creating a new user in our database with a mobile number 172 | func (s *Service) Mobile(c *gin.Context, m *request.MobileSignup) error { 173 | // find by countryCode and mobile 174 | _, err := s.userRepo.FindByMobile(m.CountryCode, m.Mobile) 175 | if err == nil { // user already exists 176 | return apperr.New(http.StatusConflict, "User already exists.") 177 | } 178 | // create and verify 179 | user := &model.User{ 180 | CountryCode: m.CountryCode, 181 | Mobile: m.Mobile, 182 | } 183 | err = s.accountRepo.CreateWithMobile(user) 184 | if err != nil { 185 | return err 186 | } 187 | // generate sms token 188 | err = s.mob.GenerateSMSToken(m.CountryCode, m.Mobile) 189 | if err != nil { 190 | apperr.Response(c, err) 191 | return err 192 | } 193 | return nil 194 | } 195 | -------------------------------------------------------------------------------- /repository/platform/query/query.go: -------------------------------------------------------------------------------- 1 | package query 2 | 3 | import ( 4 | "github.com/gogjango/gjango/apperr" 5 | "github.com/gogjango/gjango/model" 6 | ) 7 | 8 | // List prepares data for list queries 9 | func List(u *model.AuthUser) (*model.ListQuery, error) { 10 | switch true { 11 | case int(u.Role) <= 2: // user is SuperAdmin or Admin 12 | return nil, nil 13 | case u.Role == model.CompanyAdminRole: 14 | return &model.ListQuery{Query: "company_id = ?", ID: u.CompanyID}, nil 15 | case u.Role == model.LocationAdminRole: 16 | return &model.ListQuery{Query: "location_id = ?", ID: u.LocationID}, nil 17 | default: 18 | return nil, apperr.Forbidden 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /repository/platform/structs/structs.go: -------------------------------------------------------------------------------- 1 | package structs 2 | 3 | import "reflect" 4 | 5 | // Merge receives two structs, and merges them excluding fields with tag name: `structs`, value "-" 6 | func Merge(dst, src interface{}) { 7 | s := reflect.ValueOf(src) 8 | d := reflect.ValueOf(dst) 9 | if s.Kind() != reflect.Ptr || d.Kind() != reflect.Ptr { 10 | return 11 | } 12 | for i := 0; i < s.Elem().NumField(); i++ { 13 | v := s.Elem().Field(i) 14 | fieldName := s.Elem().Type().Field(i).Name 15 | skip := s.Elem().Type().Field(i).Tag.Get("structs") 16 | if skip == "-" { 17 | continue 18 | } 19 | if v.Kind() > reflect.Float64 && 20 | v.Kind() != reflect.String && 21 | v.Kind() != reflect.Struct && 22 | v.Kind() != reflect.Ptr && 23 | v.Kind() != reflect.Slice { 24 | continue 25 | } 26 | if v.Kind() == reflect.Ptr { 27 | // Field is pointer check if it's nil or set 28 | if !v.IsNil() { 29 | // Field is set assign it to dest 30 | 31 | if d.Elem().FieldByName(fieldName).Kind() == reflect.Ptr { 32 | d.Elem().FieldByName(fieldName).Set(v) 33 | continue 34 | } 35 | f := d.Elem().FieldByName(fieldName) 36 | if f.IsValid() { 37 | f.Set(v.Elem()) 38 | } 39 | } 40 | continue 41 | } 42 | d.Elem().FieldByName(fieldName).Set(v) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /repository/rbac.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "github.com/gogjango/gjango/model" 6 | ) 7 | 8 | // NewRBACService creates new RBAC service 9 | func NewRBACService(userRepo model.UserRepo) *RBACService { 10 | return &RBACService{ 11 | userRepo: userRepo, 12 | } 13 | } 14 | 15 | // RBACService is RBAC application service 16 | type RBACService struct { 17 | userRepo model.UserRepo 18 | } 19 | 20 | // EnforceRole authorizes request by AccessRole 21 | func (s *RBACService) EnforceRole(c *gin.Context, r model.AccessRole) bool { 22 | return !(c.MustGet("role").(int8) > int8(r)) 23 | } 24 | 25 | // EnforceUser checks whether the request to change user data is done by the same user 26 | func (s *RBACService) EnforceUser(c *gin.Context, ID int) bool { 27 | // TODO: Implement querying db and checking the requested user's company_id/location_id 28 | // to allow company/location admins to view the user 29 | return (c.GetInt("id") == ID) || s.isAdmin(c) 30 | } 31 | 32 | // EnforceCompany checks whether the request to apply change to company data 33 | // is done by the user belonging to the that company and that the user has role CompanyAdmin. 34 | // If user has admin role, the check for company doesnt need to pass. 35 | func (s *RBACService) EnforceCompany(c *gin.Context, ID int) bool { 36 | return (c.GetInt("company_id") == ID && s.EnforceRole(c, model.CompanyAdminRole)) || s.isAdmin(c) 37 | } 38 | 39 | // EnforceLocation checks whether the request to change location data 40 | // is done by the user belonging to the requested location 41 | func (s *RBACService) EnforceLocation(c *gin.Context, ID int) bool { 42 | return ((c.GetInt("location_id") == ID) && s.EnforceRole(c, model.LocationAdminRole)) || s.isCompanyAdmin(c) 43 | } 44 | 45 | func (s *RBACService) isAdmin(c *gin.Context) bool { 46 | return !(c.MustGet("role").(int8) > int8(model.AdminRole)) 47 | } 48 | 49 | func (s *RBACService) isCompanyAdmin(c *gin.Context) bool { 50 | // Must query company ID in database for the given user 51 | return !(c.MustGet("role").(int8) > int8(model.CompanyAdminRole)) 52 | } 53 | 54 | // AccountCreate performs auth check when creating a new account 55 | // Location admin cannot create accounts, needs to be fixed on EnforceLocation function 56 | func (s *RBACService) AccountCreate(c *gin.Context, roleID, companyID, locationID int) bool { 57 | companyCheck := s.EnforceCompany(c, companyID) 58 | locationCheck := s.EnforceLocation(c, locationID) 59 | roleCheck := s.EnforceRole(c, model.AccessRole(roleID)) 60 | return companyCheck && locationCheck && roleCheck && s.IsLowerRole(c, model.AccessRole(roleID)) 61 | } 62 | 63 | // IsLowerRole checks whether the requesting user has higher role than the user it wants to change 64 | // Used for account creation/deletion 65 | func (s *RBACService) IsLowerRole(c *gin.Context, r model.AccessRole) bool { 66 | return !(c.MustGet("role").(int8) >= int8(r)) 67 | } 68 | -------------------------------------------------------------------------------- /repository/rbac_i_test.go: -------------------------------------------------------------------------------- 1 | package repository_test 2 | 3 | import ( 4 | "net/http/httptest" 5 | "os" 6 | "path" 7 | "path/filepath" 8 | "runtime" 9 | "testing" 10 | 11 | embeddedpostgres "github.com/fergusstrange/embedded-postgres" 12 | "github.com/gin-gonic/gin" 13 | "github.com/go-pg/pg/v9" 14 | "github.com/gogjango/gjango/model" 15 | "github.com/gogjango/gjango/repository" 16 | "github.com/gogjango/gjango/repository/account" 17 | "github.com/gogjango/gjango/secret" 18 | "github.com/stretchr/testify/assert" 19 | "github.com/stretchr/testify/suite" 20 | "go.uber.org/zap" 21 | ) 22 | 23 | type RBACTestSuite struct { 24 | suite.Suite 25 | db *pg.DB 26 | postgres *embeddedpostgres.EmbeddedPostgres 27 | } 28 | 29 | func (suite *RBACTestSuite) SetupTest() { 30 | _, b, _, _ := runtime.Caller(0) 31 | d := path.Join(path.Dir(b)) 32 | projectRoot := filepath.Dir(d) 33 | tmpDir := path.Join(projectRoot, "tmp") 34 | os.RemoveAll(tmpDir) 35 | testConfig := embeddedpostgres.DefaultConfig(). 36 | Username("db_test_user"). 37 | Password("db_test_password"). 38 | Database("db_test_database"). 39 | Version(embeddedpostgres.V12). 40 | RuntimePath(tmpDir). 41 | Port(9876) 42 | 43 | suite.postgres = embeddedpostgres.NewDatabase(testConfig) 44 | err := suite.postgres.Start() 45 | assert.Equal(suite.T(), err, nil) 46 | 47 | suite.db = pg.Connect(&pg.Options{ 48 | Addr: "localhost:9876", 49 | User: "db_test_user", 50 | Password: "db_test_password", 51 | Database: "db_test_database", 52 | }) 53 | createSchema(suite.db, &model.Company{}, &model.Location{}, &model.Role{}, &model.User{}, &model.Verification{}) 54 | } 55 | 56 | func (suite *RBACTestSuite) TearDownTest() { 57 | suite.postgres.Stop() 58 | } 59 | 60 | func TestRBACTestSuiteIntegration(t *testing.T) { 61 | if testing.Short() { 62 | t.Skip("skipping integration test") 63 | return 64 | } 65 | suite.Run(t, new(RBACTestSuite)) 66 | } 67 | 68 | func (suite *RBACTestSuite) TestRBAC() { 69 | // create a context for tests 70 | resp := httptest.NewRecorder() 71 | gin.SetMode(gin.TestMode) 72 | c, _ := gin.CreateTestContext(resp) 73 | c.Set("role", int8(model.SuperAdminRole)) 74 | 75 | // create a user in our test database, which is superadmin 76 | log, _ := zap.NewDevelopment() 77 | userRepo := repository.NewUserRepo(suite.db, log) 78 | accountRepo := repository.NewAccountRepo(suite.db, log, secret.New()) 79 | rbac := repository.NewRBACService(userRepo) 80 | 81 | // ensure that our roles table is populated with default roles 82 | roleRepo := repository.NewRoleRepo(suite.db, log) 83 | err := roleRepo.CreateRoles() 84 | assert.Nil(suite.T(), err) 85 | 86 | accountService := account.NewAccountService(userRepo, accountRepo, rbac, secret.New()) 87 | err = accountService.Create(c, &model.User{ 88 | CountryCode: "+65", 89 | Mobile: "91919191", 90 | Active: true, 91 | RoleID: 5, 92 | }) 93 | 94 | assert.Nil(suite.T(), err) 95 | assert.NotNil(suite.T(), rbac) 96 | 97 | // since the current user is a superadmin, we should be able to change user data 98 | userID := 1 99 | access := rbac.EnforceUser(c, userID) 100 | assert.True(suite.T(), access) 101 | 102 | // since the current user is a superadmin, we should be able to change location data 103 | access = rbac.EnforceLocation(c, 1) 104 | assert.True(suite.T(), access) 105 | } 106 | -------------------------------------------------------------------------------- /repository/role.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "github.com/go-pg/pg/v9" 5 | "github.com/gogjango/gjango/model" 6 | "go.uber.org/zap" 7 | ) 8 | 9 | // NewRoleRepo returns a Role Repo instance 10 | func NewRoleRepo(db *pg.DB, log *zap.Logger) *RoleRepo { 11 | return &RoleRepo{db, log} 12 | } 13 | 14 | // RoleRepo represents the client for the role table 15 | type RoleRepo struct { 16 | db *pg.DB 17 | log *zap.Logger 18 | } 19 | 20 | // CreateRoles creates role objects in our database 21 | func (r *RoleRepo) CreateRoles() error { 22 | role := new(model.Role) 23 | sql := `INSERT INTO roles (id, access_level, name) VALUES (?, ?, ?) ON CONFLICT DO NOTHING` 24 | r.db.Query(role, sql, 1, model.SuperAdminRole, "superadmin") 25 | r.db.Query(role, sql, 2, model.AdminRole, "admin") 26 | r.db.Query(role, sql, 3, model.CompanyAdminRole, "companyadmin") 27 | r.db.Query(role, sql, 4, model.LocationAdminRole, "locationadmin") 28 | r.db.Query(role, sql, 5, model.UserRole, "user") 29 | return nil 30 | } 31 | -------------------------------------------------------------------------------- /repository/user.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "github.com/go-pg/pg/v9/orm" 5 | "go.uber.org/zap" 6 | 7 | "github.com/gogjango/gjango/apperr" 8 | "github.com/gogjango/gjango/model" 9 | ) 10 | 11 | const notDeleted = "deleted_at is null" 12 | 13 | // NewUserRepo returns a new UserRepo instance 14 | func NewUserRepo(db orm.DB, log *zap.Logger) *UserRepo { 15 | return &UserRepo{db, log} 16 | } 17 | 18 | // UserRepo is the client for our user model 19 | type UserRepo struct { 20 | db orm.DB 21 | log *zap.Logger 22 | } 23 | 24 | // View returns single user by ID 25 | func (u *UserRepo) View(id int) (*model.User, error) { 26 | var user = new(model.User) 27 | sql := `SELECT "user".*, "role"."id" AS "role__id", "role"."access_level" AS "role__access_level", "role"."name" AS "role__name" 28 | FROM "users" AS "user" LEFT JOIN "roles" AS "role" ON "role"."id" = "user"."role_id" 29 | WHERE ("user"."id" = ? and deleted_at is null)` 30 | _, err := u.db.QueryOne(user, sql, id) 31 | if err != nil { 32 | u.log.Warn("UserRepo Error", zap.Error(err)) 33 | return nil, apperr.NotFound 34 | } 35 | return user, nil 36 | } 37 | 38 | // FindByUsername queries for a single user by username 39 | func (u *UserRepo) FindByUsername(username string) (*model.User, error) { 40 | user := new(model.User) 41 | sql := `SELECT "user".*, "role"."id" AS "role__id", "role"."access_level" AS "role__access_level", "role"."name" AS "role__name" 42 | FROM "users" AS "user" LEFT JOIN "roles" AS "role" ON "role"."id" = "user"."role_id" 43 | WHERE ("user"."username" = ? and deleted_at is null)` 44 | _, err := u.db.QueryOne(user, sql, username) 45 | if err != nil { 46 | u.log.Warn("UserRepo Error", zap.String("Error:", err.Error())) 47 | return nil, apperr.NotFound 48 | } 49 | return user, nil 50 | } 51 | 52 | // FindByEmail queries for a single user by email 53 | func (u *UserRepo) FindByEmail(email string) (*model.User, error) { 54 | user := new(model.User) 55 | sql := `SELECT "user".*, "role"."id" AS "role__id", "role"."access_level" AS "role__access_level", "role"."name" AS "role__name" 56 | FROM "users" AS "user" LEFT JOIN "roles" AS "role" ON "role"."id" = "user"."role_id" 57 | WHERE ("user"."email" = ? and deleted_at is null)` 58 | _, err := u.db.QueryOne(user, sql, email) 59 | if err != nil { 60 | u.log.Warn("UserRepo Error", zap.String("Error:", err.Error())) 61 | return nil, apperr.NotFound 62 | } 63 | return user, nil 64 | } 65 | 66 | // FindByMobile queries for a single user by mobile (and country code) 67 | func (u *UserRepo) FindByMobile(countryCode, mobile string) (*model.User, error) { 68 | user := new(model.User) 69 | sql := `SELECT "user".*, "role"."id" AS "role__id", "role"."access_level" AS "role__access_level", "role"."name" AS "role__name" 70 | FROM "users" AS "user" LEFT JOIN "roles" AS "role" ON "role"."id" = "user"."role_id" 71 | WHERE ("user"."country_code" = ? and "user"."mobile" = ? and deleted_at is null)` 72 | _, err := u.db.QueryOne(user, sql, countryCode, mobile) 73 | if err != nil { 74 | u.log.Warn("UserRepo Error", zap.String("Error:", err.Error())) 75 | return nil, apperr.NotFound 76 | } 77 | return user, nil 78 | } 79 | 80 | // FindByToken queries for single user by token 81 | func (u *UserRepo) FindByToken(token string) (*model.User, error) { 82 | var user = new(model.User) 83 | sql := `SELECT "user".*, "role"."id" AS "role__id", "role"."access_level" AS "role__access_level", "role"."name" AS "role__name" 84 | FROM "users" AS "user" LEFT JOIN "roles" AS "role" ON "role"."id" = "user"."role_id" 85 | WHERE ("user"."token" = ? and deleted_at is null)` 86 | _, err := u.db.QueryOne(user, sql, token) 87 | if err != nil { 88 | u.log.Warn("UserRepo Error", zap.String("Error:", err.Error())) 89 | return nil, apperr.NotFound 90 | } 91 | return user, nil 92 | } 93 | 94 | // UpdateLogin updates last login and refresh token for user 95 | func (u *UserRepo) UpdateLogin(user *model.User) error { 96 | user.UpdateLastLogin() // update user object's last_login field 97 | _, err := u.db.Model(user).Column("last_login", "token").WherePK().Update() 98 | if err != nil { 99 | u.log.Warn("UserRepo Error", zap.Error(err)) 100 | } 101 | return err 102 | } 103 | 104 | // List returns list of all users retreivable for the current user, depending on role 105 | func (u *UserRepo) List(qp *model.ListQuery, p *model.Pagination) ([]model.User, error) { 106 | var users []model.User 107 | q := u.db.Model(&users).Column("user.*", "Role").Limit(p.Limit).Offset(p.Offset).Where(notDeleted).Order("user.id desc") 108 | if qp != nil { 109 | q.Where(qp.Query, qp.ID) 110 | } 111 | if err := q.Select(); err != nil { 112 | u.log.Warn("UserDB Error", zap.Error(err)) 113 | return nil, err 114 | } 115 | return users, nil 116 | } 117 | 118 | // Update updates user's contact info 119 | func (u *UserRepo) Update(user *model.User) (*model.User, error) { 120 | _, err := u.db.Model(user).Column("first_name", 121 | "last_name", "country_code", "mobile", "address", "active", "verified", "updated_at").WherePK().Update() 122 | if err != nil { 123 | u.log.Warn("UserDB Error", zap.Error(err)) 124 | } 125 | return user, err 126 | } 127 | 128 | // Delete sets deleted_at for a user 129 | func (u *UserRepo) Delete(user *model.User) error { 130 | user.Delete() 131 | _, err := u.db.Model(user).Column("deleted_at").WherePK().Update() 132 | if err != nil { 133 | u.log.Warn("UserRepo Error", zap.Error(err)) 134 | } 135 | return err 136 | } 137 | -------------------------------------------------------------------------------- /repository/user/user.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "github.com/gogjango/gjango/apperr" 6 | "github.com/gogjango/gjango/model" 7 | "github.com/gogjango/gjango/repository/platform/query" 8 | "github.com/gogjango/gjango/repository/platform/structs" 9 | ) 10 | 11 | // NewUserService create a new user application service 12 | func NewUserService(userRepo model.UserRepo, auth model.AuthService, rbac model.RBACService) *Service { 13 | return &Service{ 14 | userRepo: userRepo, 15 | auth: auth, 16 | rbac: rbac, 17 | } 18 | } 19 | 20 | // Service represents the user application service 21 | type Service struct { 22 | userRepo model.UserRepo 23 | auth model.AuthService 24 | rbac model.RBACService 25 | } 26 | 27 | // List returns list of users 28 | func (s *Service) List(c *gin.Context, p *model.Pagination) ([]model.User, error) { 29 | u := s.auth.User(c) 30 | q, err := query.List(u) 31 | if err != nil { 32 | return nil, err 33 | } 34 | return s.userRepo.List(q, p) 35 | } 36 | 37 | // View returns single user 38 | func (s *Service) View(c *gin.Context, id int) (*model.User, error) { 39 | if !s.rbac.EnforceUser(c, id) { 40 | return nil, apperr.Forbidden 41 | } 42 | return s.userRepo.View(id) 43 | } 44 | 45 | // Update contains user's information used for updating 46 | type Update struct { 47 | ID int 48 | FirstName *string 49 | LastName *string 50 | Mobile *string 51 | Phone *string 52 | Address *string 53 | } 54 | 55 | // Update updates user's contact information 56 | func (s *Service) Update(c *gin.Context, update *Update) (*model.User, error) { 57 | if !s.rbac.EnforceUser(c, update.ID) { 58 | return nil, apperr.Forbidden 59 | } 60 | u, err := s.userRepo.View(update.ID) 61 | if err != nil { 62 | return nil, err 63 | } 64 | structs.Merge(u, update) 65 | return s.userRepo.Update(u) 66 | } 67 | 68 | // Delete deletes a user 69 | func (s *Service) Delete(c *gin.Context, id int) error { 70 | u, err := s.userRepo.View(id) 71 | if err != nil { 72 | return err 73 | } 74 | if !s.rbac.IsLowerRole(c, u.Role.AccessLevel) { 75 | return apperr.Forbidden 76 | } 77 | u.Delete() 78 | return s.userRepo.Delete(u) 79 | } 80 | -------------------------------------------------------------------------------- /repository/user_i_test.go: -------------------------------------------------------------------------------- 1 | package repository_test 2 | 3 | import ( 4 | "os" 5 | "path" 6 | "path/filepath" 7 | "runtime" 8 | "testing" 9 | 10 | embeddedpostgres "github.com/fergusstrange/embedded-postgres" 11 | "github.com/go-pg/pg/v9" 12 | "github.com/gogjango/gjango/apperr" 13 | "github.com/gogjango/gjango/model" 14 | "github.com/gogjango/gjango/repository" 15 | "github.com/gogjango/gjango/secret" 16 | "github.com/stretchr/testify/assert" 17 | "github.com/stretchr/testify/suite" 18 | "go.uber.org/zap" 19 | ) 20 | 21 | type UserTestSuite struct { 22 | suite.Suite 23 | db *pg.DB 24 | dbErr *pg.DB 25 | postgres *embeddedpostgres.EmbeddedPostgres 26 | u *model.User // test user 27 | } 28 | 29 | func (suite *UserTestSuite) SetupTest() { 30 | _, b, _, _ := runtime.Caller(0) 31 | d := path.Join(path.Dir(b)) 32 | projectRoot := filepath.Dir(d) 33 | tmpDir := path.Join(projectRoot, "tmp") 34 | os.RemoveAll(tmpDir) 35 | testConfig := embeddedpostgres.DefaultConfig(). 36 | Username("db_test_user"). 37 | Password("db_test_password"). 38 | Database("db_test_database"). 39 | Version(embeddedpostgres.V12). 40 | RuntimePath(tmpDir). 41 | Port(9876) 42 | 43 | suite.postgres = embeddedpostgres.NewDatabase(testConfig) 44 | err := suite.postgres.Start() 45 | assert.Equal(suite.T(), err, nil) 46 | 47 | suite.db = pg.Connect(&pg.Options{ 48 | Addr: "localhost:9876", 49 | User: "db_test_user", 50 | Password: "db_test_password", 51 | Database: "db_test_database", 52 | }) 53 | suite.dbErr = pg.Connect(&pg.Options{ 54 | Addr: "localhost:9875", 55 | User: "db_test_user", 56 | Password: "db_test_password", 57 | Database: "db_test_database", 58 | }) 59 | suite.u = &model.User{ 60 | Username: "user", 61 | Email: "user@example.org", 62 | CountryCode: "+65", 63 | Mobile: "91919191", 64 | } 65 | createSchema(suite.db, &model.Company{}, &model.Location{}, &model.Role{}, &model.User{}, &model.Verification{}) 66 | } 67 | 68 | func (suite *UserTestSuite) TearDownTest() { 69 | suite.postgres.Stop() 70 | } 71 | 72 | func (suite *UserTestSuite) TestUserView() { 73 | cases := []struct { 74 | name string 75 | create bool 76 | user *model.User 77 | db *pg.DB 78 | wantError error 79 | wantResult *model.Verification 80 | }{ 81 | { 82 | name: "Fail: user not found", 83 | create: false, 84 | user: suite.u, 85 | db: suite.db, 86 | wantError: apperr.NotFound, 87 | }, 88 | { 89 | name: "Success: view user, find user", 90 | create: true, 91 | user: suite.u, 92 | db: suite.db, 93 | wantError: nil, 94 | }, 95 | } 96 | for _, tt := range cases { 97 | suite.T().Run(tt.name, func(t *testing.T) { 98 | log, _ := zap.NewDevelopment() 99 | userRepo := repository.NewUserRepo(tt.db, log) 100 | 101 | if tt.create { 102 | accountRepo := repository.NewAccountRepo(tt.db, log, secret.New()) 103 | _, err := accountRepo.Create(tt.user) 104 | assert.Nil(t, err) 105 | u, err := userRepo.View(tt.user.ID) 106 | assert.Nil(t, err) 107 | assert.Equal(t, tt.user.Mobile, u.Mobile) 108 | assert.False(t, u.Active) 109 | assert.False(t, u.Verified) 110 | assert.Nil(t, u.LastLogin) 111 | err = userRepo.UpdateLogin(u) 112 | assert.Nil(t, err) 113 | u, err = userRepo.View(u.ID) 114 | assert.NotNil(t, u.LastLogin) 115 | u.Active = true 116 | u.Verified = true 117 | u, err = userRepo.Update(u) 118 | assert.Nil(t, err) 119 | u, err = userRepo.View(u.ID) 120 | assert.Nil(t, err) 121 | assert.True(t, u.Active) 122 | assert.True(t, u.Verified) 123 | err = userRepo.Delete(u) 124 | assert.Nil(t, err) 125 | u, err = userRepo.View(u.ID) 126 | assert.Nil(t, u) 127 | assert.Error(t, apperr.NotFound) 128 | pag := &model.Pagination{Limit: 10, Offset: 0} 129 | users, err := userRepo.List(nil, pag) 130 | assert.Equal(suite.T(), 0, len(users)) 131 | assert.Nil(suite.T(), err) 132 | } else { 133 | u, err := userRepo.View(tt.user.ID) 134 | assert.Nil(t, u) 135 | assert.Equal(t, tt.wantError, err) 136 | u, err = userRepo.FindByMobile(tt.user.CountryCode, tt.user.Mobile) 137 | assert.Nil(t, u) 138 | assert.Equal(t, tt.wantError, err) 139 | u, err = userRepo.FindByEmail(tt.user.Email) 140 | assert.Nil(t, u) 141 | assert.Equal(t, tt.wantError, err) 142 | u, err = userRepo.FindByUsername(tt.user.Username) 143 | assert.Nil(t, u) 144 | assert.Equal(t, tt.wantError, err) 145 | u, err = userRepo.FindByToken("somerandomtokenthatdoesntexist") 146 | assert.Nil(t, u) 147 | assert.Equal(t, tt.wantError, err) 148 | } 149 | }) 150 | } 151 | } 152 | 153 | func (suite *UserTestSuite) TestUpdateLoginFailure() { 154 | u := suite.u 155 | log, _ := zap.NewDevelopment() 156 | userRepo := repository.NewUserRepo(suite.dbErr, log) 157 | err := userRepo.UpdateLogin(u) 158 | assert.NotNil(suite.T(), err) 159 | } 160 | 161 | func (suite *UserTestSuite) TestUpdateFailure() { 162 | u := suite.u 163 | log, _ := zap.NewDevelopment() 164 | userRepo := repository.NewUserRepo(suite.dbErr, log) 165 | u.Address = "some address" 166 | user, err := userRepo.Update(u) 167 | assert.NotNil(suite.T(), user) 168 | assert.NotNil(suite.T(), err) 169 | } 170 | 171 | func (suite *UserTestSuite) TestDeleteFailure() { 172 | u := suite.u 173 | log, _ := zap.NewDevelopment() 174 | userRepo := repository.NewUserRepo(suite.dbErr, log) 175 | err := userRepo.Delete(u) 176 | assert.NotNil(suite.T(), err) 177 | } 178 | 179 | func (suite *UserTestSuite) TestListFailure() { 180 | log, _ := zap.NewDevelopment() 181 | userRepo := repository.NewUserRepo(suite.dbErr, log) 182 | qp := &model.ListQuery{} 183 | pag := &model.Pagination{Limit: 10, Offset: 0} 184 | _, err := userRepo.List(qp, pag) 185 | assert.NotNil(suite.T(), err) 186 | } 187 | 188 | func TestUserTestSuiteIntegration(t *testing.T) { 189 | if testing.Short() { 190 | t.Skip("skipping integration test") 191 | return 192 | } 193 | suite.Run(t, new(UserTestSuite)) 194 | } 195 | -------------------------------------------------------------------------------- /repository/user_test.go: -------------------------------------------------------------------------------- 1 | package repository_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/go-pg/pg/v9/orm" 7 | "github.com/gogjango/gjango/mockgopg" 8 | "github.com/gogjango/gjango/model" 9 | "github.com/gogjango/gjango/repository" 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/suite" 12 | "go.uber.org/zap" 13 | ) 14 | 15 | type UserUnitTestSuite struct { 16 | suite.Suite 17 | mock *mockgopg.SQLMock 18 | u *model.User 19 | userRepo *repository.UserRepo 20 | } 21 | 22 | func (suite *UserUnitTestSuite) SetupTest() { 23 | var err error 24 | var db orm.DB 25 | db, suite.mock, err = mockgopg.NewGoPGDBTest() 26 | if err != nil { 27 | suite.T().Fatalf("an error '%s' was not expected when opening a stub database connection", err) 28 | } 29 | suite.u = &model.User{ 30 | Username: "hello", 31 | Email: "hello@world.org", 32 | CountryCode: "+65", 33 | Mobile: "91919191", 34 | Token: "someusertoken", 35 | } 36 | 37 | log, _ := zap.NewDevelopment() 38 | suite.userRepo = repository.NewUserRepo(db, log) 39 | } 40 | 41 | func (suite *UserUnitTestSuite) TearDownTest() { 42 | suite.mock.FlushAll() 43 | } 44 | 45 | func TestUserUnitTestSuite(t *testing.T) { 46 | suite.Run(t, new(UserUnitTestSuite)) 47 | } 48 | 49 | func (suite *UserUnitTestSuite) TestFindByUsernameSuccess() { 50 | u := suite.u 51 | userRepo := suite.userRepo 52 | t := suite.T() 53 | mock := suite.mock 54 | 55 | sql := `SELECT "user".*, "role"."id" AS "role__id", "role"."access_level" AS "role__access_level", "role"."name" AS "role__name" 56 | FROM "users" AS "user" LEFT JOIN "roles" AS "role" ON "role"."id" = "user"."role_id" 57 | WHERE ("user"."username" = ? and deleted_at is null)` 58 | mock.ExpectQueryOne(sql). 59 | WithArgs(u.Username). 60 | Returns(mockgopg.NewResult(1, 1, u), nil) 61 | 62 | uReturned, err := userRepo.FindByUsername("hello") 63 | assert.Equal(t, u.Username, uReturned.Username) 64 | assert.Nil(t, err) 65 | } 66 | 67 | func (suite *UserUnitTestSuite) TestFindByEmailSuccess() { 68 | u := suite.u 69 | userRepo := suite.userRepo 70 | t := suite.T() 71 | mock := suite.mock 72 | 73 | sql := `SELECT "user".*, "role"."id" AS "role__id", "role"."access_level" AS "role__access_level", "role"."name" AS "role__name" 74 | FROM "users" AS "user" LEFT JOIN "roles" AS "role" ON "role"."id" = "user"."role_id" 75 | WHERE ("user"."email" = ? and deleted_at is null)` 76 | mock.ExpectQueryOne(sql). 77 | WithArgs(u.Email). 78 | Returns(mockgopg.NewResult(1, 1, u), nil) 79 | 80 | uReturned, err := userRepo.FindByEmail("hello@world.org") 81 | assert.Equal(t, u.Email, uReturned.Email) 82 | assert.Nil(t, err) 83 | } 84 | 85 | func (suite *UserUnitTestSuite) TestFindByMobileSuccess() { 86 | u := suite.u 87 | userRepo := suite.userRepo 88 | t := suite.T() 89 | mock := suite.mock 90 | 91 | sql := `SELECT "user".*, "role"."id" AS "role__id", "role"."access_level" AS "role__access_level", "role"."name" AS "role__name" 92 | FROM "users" AS "user" LEFT JOIN "roles" AS "role" ON "role"."id" = "user"."role_id" 93 | WHERE ("user"."country_code" = ? and "user"."mobile" = ? and deleted_at is null)` 94 | mock.ExpectQueryOne(sql). 95 | WithArgs(u.CountryCode, u.Mobile). 96 | Returns(mockgopg.NewResult(1, 1, u), nil) 97 | 98 | uReturned, err := userRepo.FindByMobile(u.CountryCode, u.Mobile) 99 | assert.Equal(t, u.Mobile, uReturned.Mobile) 100 | assert.Nil(t, err) 101 | } 102 | 103 | func (suite *UserUnitTestSuite) TestFindByTokenSuccess() { 104 | u := suite.u 105 | userRepo := suite.userRepo 106 | t := suite.T() 107 | mock := suite.mock 108 | 109 | u.Token = "someusertoken" 110 | 111 | var user = new(model.User) 112 | user.Token = "someusertoken" 113 | user.ID = 1 114 | sql := `SELECT "user".*, "role"."id" AS "role__id", "role"."access_level" AS "role__access_level", "role"."name" AS "role__name" 115 | FROM "users" AS "user" LEFT JOIN "roles" AS "role" ON "role"."id" = "user"."role_id" 116 | WHERE ("user"."token" = ? and deleted_at is null)` 117 | mock.ExpectQueryOne(sql). 118 | WithArgs("someusertoken"). 119 | Returns(mockgopg.NewResult(1, 1, user), nil) 120 | 121 | _, err := userRepo.FindByToken(u.Token) 122 | assert.Equal(t, u.Token, user.Token) 123 | assert.Nil(t, err) 124 | } 125 | -------------------------------------------------------------------------------- /request/account.go: -------------------------------------------------------------------------------- 1 | package request 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | "github.com/gogjango/gjango/apperr" 8 | "github.com/gogjango/gjango/model" 9 | ) 10 | 11 | // RegisterAdmin contains admin registration request 12 | type RegisterAdmin struct { 13 | FirstName string `json:"first_name" binding:"required"` 14 | LastName string `json:"last_name" binding:"required"` 15 | Username string `json:"username" binding:"required,min=3,alphanum"` 16 | Password string `json:"password" binding:"required,min=8"` 17 | PasswordConfirm string `json:"password_confirm" binding:"required"` 18 | Email string `json:"email" binding:"required,email"` 19 | 20 | CompanyID int `json:"company_id" binding:"required"` 21 | LocationID int `json:"location_id" binding:"required"` 22 | RoleID int `json:"role_id" binding:"required"` 23 | } 24 | 25 | // AccountCreate validates account creation request 26 | func AccountCreate(c *gin.Context) (*RegisterAdmin, error) { 27 | var r RegisterAdmin 28 | if err := c.ShouldBindJSON(&r); err != nil { 29 | apperr.Response(c, err) 30 | return nil, err 31 | } 32 | if r.Password != r.PasswordConfirm { 33 | err := apperr.New(http.StatusBadRequest, "passwords do not match") 34 | c.AbortWithStatusJSON(http.StatusBadRequest, err) 35 | return nil, err 36 | } 37 | if r.RoleID < int(model.SuperAdminRole) || r.RoleID > int(model.UserRole) { 38 | c.AbortWithStatus(http.StatusBadRequest) 39 | return nil, apperr.BadRequest 40 | } 41 | return &r, nil 42 | } 43 | 44 | // Password contains password change request 45 | type Password struct { 46 | ID int `json:"-"` 47 | OldPassword string `json:"old_password" binding:"required,min=8"` 48 | NewPassword string `json:"new_password" binding:"required,min=8"` 49 | NewPasswordConfirm string `json:"new_password_confirm" binding:"required"` 50 | } 51 | 52 | // PasswordChange validates password change request 53 | func PasswordChange(c *gin.Context) (*Password, error) { 54 | var p Password 55 | id, err := ID(c) 56 | if err != nil { 57 | return nil, err 58 | } 59 | if err := c.ShouldBindJSON(&p); err != nil { 60 | apperr.Response(c, err) 61 | return nil, err 62 | } 63 | if p.NewPassword != p.NewPasswordConfirm { 64 | err := apperr.New(http.StatusBadRequest, "passwords do not match") 65 | apperr.Response(c, err) 66 | return nil, err 67 | } 68 | p.ID = id 69 | return &p, nil 70 | } 71 | -------------------------------------------------------------------------------- /request/auth.go: -------------------------------------------------------------------------------- 1 | package request 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "github.com/gogjango/gjango/apperr" 6 | ) 7 | 8 | // Credentials stores the username and password provided in the request 9 | type Credentials struct { 10 | Email string `json:"email" binding:"required"` 11 | Password string `json:"password" binding:"required"` 12 | } 13 | 14 | // Login parses out the username and password in gin's request context, into Credentials 15 | func Login(c *gin.Context) (*Credentials, error) { 16 | cred := new(Credentials) 17 | if err := c.ShouldBindJSON(cred); err != nil { 18 | apperr.Response(c, err) 19 | return nil, err 20 | } 21 | return cred, nil 22 | } 23 | -------------------------------------------------------------------------------- /request/request.go: -------------------------------------------------------------------------------- 1 | package request 2 | 3 | import ( 4 | "net/http" 5 | "strconv" 6 | 7 | "github.com/gin-gonic/gin" 8 | "github.com/gogjango/gjango/apperr" 9 | ) 10 | 11 | const ( 12 | defaultLimit = 100 13 | maxLimit = 1000 14 | ) 15 | 16 | // Pagination contains pagination request 17 | type Pagination struct { 18 | Limit int `form:"limit"` 19 | Page int `form:"page" binding:"min=0"` 20 | Offset int `json:"-"` 21 | } 22 | 23 | // Paginate validates pagination requests 24 | func Paginate(c *gin.Context) (*Pagination, error) { 25 | p := new(Pagination) 26 | if err := c.ShouldBindQuery(p); err != nil { 27 | apperr.Response(c, err) 28 | return nil, err 29 | } 30 | if p.Limit < 1 { 31 | p.Limit = defaultLimit 32 | } 33 | if p.Limit > 1000 { 34 | p.Limit = maxLimit 35 | } 36 | p.Offset = p.Limit * p.Page 37 | return p, nil 38 | } 39 | 40 | // ID returns id url parameter. 41 | // In case of conversion error to int, request will be aborted with StatusBadRequest. 42 | func ID(c *gin.Context) (int, error) { 43 | id, err := strconv.Atoi(c.Param("id")) 44 | if err != nil { 45 | c.AbortWithStatus(http.StatusBadRequest) 46 | return 0, apperr.BadRequest 47 | } 48 | return id, nil 49 | } 50 | -------------------------------------------------------------------------------- /request/signup.go: -------------------------------------------------------------------------------- 1 | package request 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | "github.com/gogjango/gjango/apperr" 8 | ) 9 | 10 | // EmailSignup contains the user signup request 11 | type EmailSignup struct { 12 | Email string `json:"email" binding:"required,min=3,email"` 13 | Password string `json:"password" binding:"required,min=8"` 14 | PasswordConfirm string `json:"password_confirm" binding:"required"` 15 | } 16 | 17 | // AccountSignup validates user signup request 18 | func AccountSignup(c *gin.Context) (*EmailSignup, error) { 19 | var r EmailSignup 20 | if err := c.ShouldBindJSON(&r); err != nil { 21 | apperr.Response(c, err) 22 | return nil, err 23 | } 24 | if r.Password != r.PasswordConfirm { 25 | err := apperr.New(http.StatusBadRequest, "passwords do not match") 26 | c.AbortWithStatusJSON(http.StatusBadRequest, err) 27 | return nil, err 28 | } 29 | return &r, nil 30 | } 31 | 32 | // MobileSignup contains the user signup request with a mobile number 33 | type MobileSignup struct { 34 | CountryCode string `json:"country_code" binding:"required,min=2"` 35 | Mobile string `json:"mobile" binding:"required"` 36 | } 37 | 38 | // Mobile validates user signup request via mobile 39 | func Mobile(c *gin.Context) (*MobileSignup, error) { 40 | var r MobileSignup 41 | if err := c.ShouldBindJSON(&r); err != nil { 42 | apperr.Response(c, err) 43 | return nil, err 44 | } 45 | return &r, nil 46 | } 47 | 48 | // MobileVerify contains the user's mobile verification country code, mobile number and verification code 49 | type MobileVerify struct { 50 | CountryCode string `json:"country_code" binding:"required,min=2"` 51 | Mobile string `json:"mobile" binding:"required"` 52 | Code string `json:"code" binding:"required"` 53 | Signup bool `json:"signup" binding:"required"` 54 | } 55 | 56 | // AccountVerifyMobile validates user mobile verification 57 | func AccountVerifyMobile(c *gin.Context) (*MobileVerify, error) { 58 | var r MobileVerify 59 | if err := c.ShouldBindJSON(&r); err != nil { 60 | return nil, err 61 | } 62 | return &r, nil 63 | } 64 | -------------------------------------------------------------------------------- /request/user.go: -------------------------------------------------------------------------------- 1 | package request 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "github.com/gogjango/gjango/apperr" 6 | ) 7 | 8 | // UpdateUser contains user update data from json request 9 | type UpdateUser struct { 10 | ID int `json:"-"` 11 | FirstName *string `json:"first_name,omitempty" binding:"omitempty,min=2"` 12 | LastName *string `json:"last_name,omitempty" binding:"omitempty,min=2"` 13 | Mobile *string `json:"mobile,omitempty"` 14 | Phone *string `json:"phone,omitempty"` 15 | Address *string `json:"address,omitempty"` 16 | } 17 | 18 | // UserUpdate validates user update request 19 | func UserUpdate(c *gin.Context) (*UpdateUser, error) { 20 | var u UpdateUser 21 | id, err := ID(c) 22 | if err != nil { 23 | return nil, err 24 | } 25 | if err := c.ShouldBindJSON(&u); err != nil { 26 | apperr.Response(c, err) 27 | return nil, err 28 | } 29 | u.ID = id 30 | return &u, nil 31 | } 32 | -------------------------------------------------------------------------------- /route/custom_route.go: -------------------------------------------------------------------------------- 1 | package route 2 | 3 | // ServicesI is the interface for our user-defined custom routes and related services 4 | type ServicesI interface { 5 | SetupRoutes() 6 | } 7 | -------------------------------------------------------------------------------- /route/route.go: -------------------------------------------------------------------------------- 1 | package route 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "github.com/go-pg/pg/v9" 6 | "github.com/gogjango/gjango/mail" 7 | mw "github.com/gogjango/gjango/middleware" 8 | "github.com/gogjango/gjango/mobile" 9 | "github.com/gogjango/gjango/repository" 10 | "github.com/gogjango/gjango/repository/account" 11 | "github.com/gogjango/gjango/repository/auth" 12 | "github.com/gogjango/gjango/repository/user" 13 | "github.com/gogjango/gjango/secret" 14 | "github.com/gogjango/gjango/service" 15 | "go.uber.org/zap" 16 | ) 17 | 18 | // NewServices creates a new router services 19 | func NewServices(DB *pg.DB, Log *zap.Logger, JWT *mw.JWT, Mail mail.Service, Mobile mobile.Service, R *gin.Engine) *Services { 20 | return &Services{DB, Log, JWT, Mail, Mobile, R} 21 | } 22 | 23 | // Services lets us bind specific services when setting up routes 24 | type Services struct { 25 | DB *pg.DB 26 | Log *zap.Logger 27 | JWT *mw.JWT 28 | Mail mail.Service 29 | Mobile mobile.Service 30 | R *gin.Engine 31 | } 32 | 33 | // SetupV1Routes instances various repos and services and sets up the routers 34 | func (s *Services) SetupV1Routes() { 35 | // database logic 36 | userRepo := repository.NewUserRepo(s.DB, s.Log) 37 | accountRepo := repository.NewAccountRepo(s.DB, s.Log, secret.New()) 38 | rbac := repository.NewRBACService(userRepo) 39 | 40 | // service logic 41 | authService := auth.NewAuthService(userRepo, accountRepo, s.JWT, s.Mail, s.Mobile) 42 | accountService := account.NewAccountService(userRepo, accountRepo, rbac, secret.New()) 43 | userService := user.NewUserService(userRepo, authService, rbac) 44 | 45 | // no prefix, no jwt 46 | service.AuthRouter(authService, s.R) 47 | 48 | // prefixed with /v1 and protected by jwt 49 | v1Router := s.R.Group("/v1") 50 | v1Router.Use(s.JWT.MWFunc()) 51 | service.AccountRouter(accountService, v1Router) 52 | service.UserRouter(userService, v1Router) 53 | } 54 | -------------------------------------------------------------------------------- /secret/cryptorandom.go: -------------------------------------------------------------------------------- 1 | package secret 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/base64" 6 | ) 7 | 8 | // GenerateRandomBytes returns securely generated random bytes. 9 | // It will return an error if the system's secure random 10 | // number generator fails to function correctly, in which 11 | // case the caller should not continue. 12 | func GenerateRandomBytes(n int) ([]byte, error) { 13 | b := make([]byte, n) 14 | _, err := rand.Read(b) 15 | // Note that err == nil only if we read len(b) bytes. 16 | if err != nil { 17 | return nil, err 18 | } 19 | 20 | return b, nil 21 | } 22 | 23 | // GenerateRandomString returns a securely generated random string. 24 | // It will return an error if the system's secure random 25 | // number generator fails to function correctly, in which 26 | // case the caller should not continue. 27 | // Example: this will give us a 32 byte output 28 | // token, err = GenerateRandomString(32) 29 | func GenerateRandomString(n int) (string, error) { 30 | const letters = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-" 31 | bytes, err := GenerateRandomBytes(n) 32 | if err != nil { 33 | return "", err 34 | } 35 | for i, b := range bytes { 36 | bytes[i] = letters[b%byte(len(letters))] 37 | } 38 | return string(bytes), nil 39 | } 40 | 41 | // GenerateRandomStringURLSafe returns a URL-safe, base64 encoded 42 | // securely generated random string. 43 | // It will return an error if the system's secure random 44 | // number generator fails to function correctly, in which 45 | // case the caller should not continue. 46 | // Example: this will give us a 44 byte, base64 encoded output 47 | // token, err := GenerateRandomStringURLSafe(32) 48 | func GenerateRandomStringURLSafe(n int) (string, error) { 49 | b, err := GenerateRandomBytes(n) 50 | return base64.URLEncoding.EncodeToString(b), err 51 | } 52 | -------------------------------------------------------------------------------- /secret/secret.go: -------------------------------------------------------------------------------- 1 | package secret 2 | 3 | import ( 4 | "golang.org/x/crypto/bcrypt" 5 | ) 6 | 7 | // New returns a password object 8 | func New() *Password { 9 | return &Password{} 10 | } 11 | 12 | // Password is our secret service implementation 13 | type Password struct{} 14 | 15 | // HashPassword hashes the password using bcrypt 16 | func (p *Password) HashPassword(password string) string { 17 | hashedPW, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) 18 | return string(hashedPW) 19 | } 20 | 21 | // HashMatchesPassword matches hash with password. Returns true if hash and password match. 22 | func (p *Password) HashMatchesPassword(hash, password string) bool { 23 | return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil 24 | } 25 | 26 | // HashRandomPassword creates a random password for passwordless mobile signup 27 | func (p *Password) HashRandomPassword() (string, error) { 28 | randomPassword, err := GenerateRandomString(16) 29 | if err != nil { 30 | return "", err 31 | } 32 | r := p.HashPassword(randomPassword) 33 | return r, nil 34 | } 35 | -------------------------------------------------------------------------------- /secret/secret_interface.go: -------------------------------------------------------------------------------- 1 | package secret 2 | 3 | // Service is the interface to our secret service 4 | type Service interface { 5 | HashPassword(password string) string 6 | HashMatchesPassword(hash, password string) bool 7 | HashRandomPassword() (string, error) 8 | } 9 | -------------------------------------------------------------------------------- /server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/gin-contrib/cors" 7 | "github.com/gin-gonic/gin" 8 | "github.com/gogjango/gjango/config" 9 | "github.com/gogjango/gjango/mail" 10 | mw "github.com/gogjango/gjango/middleware" 11 | "github.com/gogjango/gjango/mobile" 12 | "github.com/gogjango/gjango/route" 13 | 14 | "go.uber.org/zap" 15 | ) 16 | 17 | // Server holds all the routes and their services 18 | type Server struct { 19 | RouteServices []route.ServicesI 20 | } 21 | 22 | // Run runs our API server 23 | func (server *Server) Run(env string) error { 24 | 25 | // load configuration 26 | j := config.LoadJWT(env) 27 | 28 | r := gin.Default() 29 | 30 | // middleware 31 | mw.Add(r, cors.Default()) 32 | jwt := mw.NewJWT(j) 33 | m := mail.NewMail(config.GetMailConfig(), config.GetSiteConfig()) 34 | mobile := mobile.NewMobile(config.GetTwilioConfig()) 35 | db := config.GetConnection() 36 | log, _ := zap.NewDevelopment() 37 | defer log.Sync() 38 | 39 | // setup default routes 40 | rsDefault := &route.Services{ 41 | DB: db, 42 | Log: log, 43 | JWT: jwt, 44 | Mail: m, 45 | Mobile: mobile, 46 | R: r} 47 | rsDefault.SetupV1Routes() 48 | 49 | // setup all custom/user-defined route services 50 | for _, rs := range server.RouteServices { 51 | rs.SetupRoutes() 52 | } 53 | 54 | port, ok := os.LookupEnv("PORT") 55 | if !ok { 56 | port = "8080" 57 | } 58 | 59 | // run with port from config 60 | return r.Run(":" + port) 61 | } 62 | -------------------------------------------------------------------------------- /service/account.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | "github.com/gogjango/gjango/apperr" 8 | "github.com/gogjango/gjango/model" 9 | "github.com/gogjango/gjango/repository/account" 10 | "github.com/gogjango/gjango/request" 11 | ) 12 | 13 | // AccountService represents the account http service 14 | type AccountService struct { 15 | svc *account.Service 16 | } 17 | 18 | // AccountRouter sets up all the controller functions to our router 19 | func AccountRouter(svc *account.Service, r *gin.RouterGroup) { 20 | a := AccountService{ 21 | svc: svc, 22 | } 23 | ar := r.Group("/users") 24 | ar.POST("", a.create) 25 | ar.PATCH("/:id/password", a.changePassword) 26 | } 27 | 28 | func (a *AccountService) create(c *gin.Context) { 29 | r, err := request.AccountCreate(c) 30 | if err != nil { 31 | return 32 | } 33 | user := &model.User{ 34 | Username: r.Username, 35 | Password: r.Password, 36 | Email: r.Email, 37 | FirstName: r.FirstName, 38 | LastName: r.LastName, 39 | CompanyID: r.CompanyID, 40 | LocationID: r.LocationID, 41 | RoleID: r.RoleID, 42 | } 43 | if err := a.svc.Create(c, user); err != nil { 44 | apperr.Response(c, err) 45 | return 46 | } 47 | c.JSON(http.StatusOK, user) 48 | } 49 | 50 | func (a *AccountService) changePassword(c *gin.Context) { 51 | p, err := request.PasswordChange(c) 52 | if err != nil { 53 | return 54 | } 55 | if err := a.svc.ChangePassword(c, p.OldPassword, p.NewPassword, p.ID); err != nil { 56 | apperr.Response(c, err) 57 | return 58 | } 59 | c.Status(http.StatusOK) 60 | } 61 | -------------------------------------------------------------------------------- /service/account_test.go: -------------------------------------------------------------------------------- 1 | package service_test 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | 10 | "github.com/gin-gonic/gin" 11 | "github.com/gogjango/gjango/mock" 12 | "github.com/gogjango/gjango/mock/mockdb" 13 | "github.com/gogjango/gjango/model" 14 | "github.com/gogjango/gjango/repository/account" 15 | "github.com/gogjango/gjango/secret" 16 | "github.com/gogjango/gjango/service" 17 | "github.com/stretchr/testify/assert" 18 | ) 19 | 20 | func TestCreate(t *testing.T) { 21 | cases := []struct { 22 | name string 23 | req string 24 | wantStatus int 25 | wantResp *model.User 26 | accountRepo *mockdb.Account 27 | rbac *mock.RBAC 28 | }{ 29 | { 30 | name: "Invalid request", 31 | 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}`, 32 | wantStatus: http.StatusBadRequest, 33 | }, 34 | { 35 | name: "Fail on userSvc", 36 | 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}`, 37 | rbac: &mock.RBAC{ 38 | AccountCreateFn: func(c *gin.Context, roleID, companyID, locationID int) bool { 39 | return false 40 | }, 41 | }, 42 | wantStatus: http.StatusForbidden, 43 | }, 44 | { 45 | name: "Success", 46 | 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}`, 47 | rbac: &mock.RBAC{ 48 | AccountCreateFn: func(c *gin.Context, roleID, companyID, locationID int) bool { 49 | return true 50 | }, 51 | }, 52 | accountRepo: &mockdb.Account{ 53 | CreateFn: func(usr *model.User) (*model.User, error) { 54 | usr.ID = 1 55 | usr.CreatedAt = mock.TestTime(2018) 56 | usr.UpdatedAt = mock.TestTime(2018) 57 | return usr, nil 58 | }, 59 | }, 60 | wantResp: &model.User{ 61 | Base: model.Base{ 62 | CreatedAt: mock.TestTime(2018), 63 | UpdatedAt: mock.TestTime(2018), 64 | }, 65 | ID: 1, 66 | FirstName: "John", 67 | LastName: "Doe", 68 | Username: "juzernejm", 69 | Email: "johndoe@gmail.com", 70 | CompanyID: 1, 71 | LocationID: 2, 72 | }, 73 | wantStatus: http.StatusOK, 74 | }, 75 | } 76 | 77 | gin.SetMode(gin.TestMode) 78 | 79 | for _, tt := range cases { 80 | t.Run(tt.name, func(t *testing.T) { 81 | r := gin.New() 82 | rg := r.Group("/v1") 83 | accountService := account.NewAccountService(nil, tt.accountRepo, tt.rbac, secret.New()) 84 | service.AccountRouter(accountService, rg) 85 | ts := httptest.NewServer(r) 86 | defer ts.Close() 87 | path := ts.URL + "/v1/users" 88 | res, err := http.Post(path, "application/json", bytes.NewBufferString(tt.req)) 89 | if err != nil { 90 | t.Fatal(err) 91 | } 92 | defer res.Body.Close() 93 | if tt.wantResp != nil { 94 | response := new(model.User) 95 | if err := json.NewDecoder(res.Body).Decode(response); err != nil { 96 | t.Fatal(err) 97 | } 98 | assert.Equal(t, tt.wantResp, response) 99 | } 100 | assert.Equal(t, tt.wantStatus, res.StatusCode) 101 | }) 102 | } 103 | } 104 | 105 | func TestChangePassword(t *testing.T) { 106 | cases := []struct { 107 | name string 108 | req string 109 | wantStatus int 110 | id string 111 | userRepo *mockdb.User 112 | accountRepo *mockdb.Account 113 | rbac *mock.RBAC 114 | }{ 115 | { 116 | name: "Invalid request", 117 | req: `{"new_password":"new_password","old_password":"my_old_password", "new_password_confirm":"new_password_cf"}`, 118 | wantStatus: http.StatusBadRequest, 119 | id: "1", 120 | }, 121 | { 122 | name: "Fail on RBAC", 123 | req: `{"new_password":"newpassw","old_password":"oldpassw", "new_password_confirm":"newpassw"}`, 124 | rbac: &mock.RBAC{ 125 | EnforceUserFn: func(c *gin.Context, id int) bool { 126 | return false 127 | }, 128 | }, 129 | id: "1", 130 | wantStatus: http.StatusForbidden, 131 | }, 132 | { 133 | name: "Success", 134 | req: `{"new_password":"newpassw","old_password":"oldpassw", "new_password_confirm":"newpassw"}`, 135 | rbac: &mock.RBAC{ 136 | EnforceUserFn: func(c *gin.Context, id int) bool { 137 | return true 138 | }, 139 | }, 140 | id: "1", 141 | userRepo: &mockdb.User{ 142 | ViewFn: func(id int) (*model.User, error) { 143 | return &model.User{ 144 | Password: secret.New().HashPassword("oldpassw"), 145 | }, nil 146 | }, 147 | }, 148 | accountRepo: &mockdb.Account{ 149 | ChangePasswordFn: func(usr *model.User) error { 150 | return nil 151 | }, 152 | }, 153 | wantStatus: http.StatusOK, 154 | }, 155 | } 156 | gin.SetMode(gin.TestMode) 157 | client := &http.Client{} 158 | 159 | for _, tt := range cases { 160 | t.Run(tt.name, func(t *testing.T) { 161 | r := gin.New() 162 | rg := r.Group("/v1") 163 | accountService := account.NewAccountService(tt.userRepo, tt.accountRepo, tt.rbac, secret.New()) 164 | service.AccountRouter(accountService, rg) 165 | ts := httptest.NewServer(r) 166 | defer ts.Close() 167 | path := ts.URL + "/v1/users/" + tt.id + "/password" 168 | req, err := http.NewRequest("PATCH", path, bytes.NewBufferString(tt.req)) 169 | if err != nil { 170 | t.Fatal(err) 171 | } 172 | res, err := client.Do(req) 173 | if err != nil { 174 | t.Fatal(err) 175 | } 176 | defer res.Body.Close() 177 | assert.Equal(t, tt.wantStatus, res.StatusCode) 178 | }) 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /service/auth.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | "github.com/gogjango/gjango/apperr" 8 | "github.com/gogjango/gjango/repository/auth" 9 | "github.com/gogjango/gjango/request" 10 | ) 11 | 12 | // AuthRouter creates new auth http service 13 | func AuthRouter(svc *auth.Service, r *gin.Engine) { 14 | a := Auth{svc} 15 | r.POST("/mobile", a.mobile) // mobile: passwordless authentication which handles both the signup scenario and the login scenario 16 | r.POST("/signup", a.signup) // email: creates user object 17 | r.POST("/login", a.login) 18 | r.GET("/refresh/:token", a.refresh) 19 | r.GET("/verification/:token", a.verify) // email: on verification token submission, mark user as verified and return jwt 20 | r.POST("/mobile/verify", a.mobileVerify) // mobile: on sms code submission, either mark user as verified and return jwt, or update last_login and return jwt 21 | } 22 | 23 | // Auth represents auth http service 24 | type Auth struct { 25 | svc *auth.Service 26 | } 27 | 28 | func (a *Auth) login(c *gin.Context) { 29 | cred, err := request.Login(c) 30 | if err != nil { 31 | return 32 | } 33 | r, err := a.svc.Authenticate(c, cred.Email, cred.Password) 34 | if err != nil { 35 | apperr.Response(c, err) 36 | return 37 | } 38 | c.JSON(http.StatusOK, r) 39 | } 40 | 41 | func (a *Auth) refresh(c *gin.Context) { 42 | refreshToken := c.Param("token") 43 | r, err := a.svc.Refresh(c, refreshToken) 44 | if err != nil { 45 | apperr.Response(c, err) 46 | return 47 | } 48 | c.JSON(http.StatusOK, r) 49 | } 50 | 51 | func (a *Auth) signup(c *gin.Context) { 52 | e, err := request.AccountSignup(c) 53 | if err != nil { 54 | apperr.Response(c, err) 55 | return 56 | } 57 | err = a.svc.Signup(c, e) 58 | if err != nil { 59 | apperr.Response(c, err) 60 | return 61 | } 62 | c.Status(http.StatusCreated) 63 | } 64 | 65 | func (a *Auth) verify(c *gin.Context) { 66 | token := c.Param("token") 67 | err := a.svc.Verify(c, token) 68 | if err != nil { 69 | apperr.Response(c, err) 70 | return 71 | } 72 | c.Status(http.StatusOK) 73 | } 74 | 75 | // mobile handles a passwordless mobile signup/login 76 | // if user with country_code and mobile already exists, simply return 200 77 | // if user does not exist yet, we attempt to create the new user object, on success 201, otherwise 500 78 | // the client should call /mobile/verify next, if it receives 201 (newly created user object) or 200 (success, and user was previously created) 79 | // we can use this status code in the client to prepare our request object with Signup attribute as true (201) or false (200) 80 | func (a *Auth) mobile(c *gin.Context) { 81 | m, err := request.Mobile(c) 82 | if err != nil { 83 | apperr.Response(c, err) 84 | return 85 | } 86 | err = a.svc.Mobile(c, m) 87 | if err != nil { 88 | if err.Error() == "User already exists." { 89 | c.Status(http.StatusOK) 90 | return 91 | } 92 | apperr.Response(c, err) 93 | return 94 | } 95 | c.Status(http.StatusCreated) 96 | } 97 | 98 | // mobileVerify handles the next API call after the previous client call to /mobile 99 | // we mark user verified AND return jwt 100 | func (a *Auth) mobileVerify(c *gin.Context) { 101 | m, err := request.AccountVerifyMobile(c) 102 | if err != nil { 103 | c.JSON(http.StatusInternalServerError, nil) 104 | return 105 | } 106 | r, err := a.svc.MobileVerify(c, m.CountryCode, m.Mobile, m.Code, m.Signup) 107 | if err != nil { 108 | c.JSON(http.StatusUnauthorized, nil) 109 | return 110 | } 111 | c.JSON(http.StatusOK, r) 112 | } 113 | -------------------------------------------------------------------------------- /service/user.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gin-gonic/gin" 7 | "github.com/gogjango/gjango/apperr" 8 | "github.com/gogjango/gjango/model" 9 | "github.com/gogjango/gjango/repository/user" 10 | "github.com/gogjango/gjango/request" 11 | ) 12 | 13 | // User represents the user http service 14 | type User struct { 15 | svc *user.Service 16 | } 17 | 18 | // UserRouter declares the routes for users router group 19 | func UserRouter(svc *user.Service, r *gin.RouterGroup) { 20 | u := User{ 21 | svc: svc, 22 | } 23 | ur := r.Group("/users") 24 | ur.GET("", u.list) 25 | ur.GET("/:id", u.view) 26 | ur.PATCH("/:id", u.update) 27 | ur.DELETE("/:id", u.delete) 28 | } 29 | 30 | type listResponse struct { 31 | Users []model.User `json:"users"` 32 | Page int `json:"page"` 33 | } 34 | 35 | func (u *User) list(c *gin.Context) { 36 | p, err := request.Paginate(c) 37 | if err != nil { 38 | return 39 | } 40 | result, err := u.svc.List(c, &model.Pagination{ 41 | Limit: p.Limit, Offset: p.Offset, 42 | }) 43 | if err != nil { 44 | apperr.Response(c, err) 45 | return 46 | } 47 | c.JSON(http.StatusOK, listResponse{ 48 | Users: result, 49 | Page: p.Page, 50 | }) 51 | } 52 | 53 | func (u *User) view(c *gin.Context) { 54 | id, err := request.ID(c) 55 | if err != nil { 56 | return 57 | } 58 | result, err := u.svc.View(c, id) 59 | if err != nil { 60 | apperr.Response(c, err) 61 | return 62 | } 63 | c.JSON(http.StatusOK, result) 64 | } 65 | 66 | func (u *User) update(c *gin.Context) { 67 | updateUser, err := request.UserUpdate(c) 68 | if err != nil { 69 | return 70 | } 71 | userUpdate, err := u.svc.Update(c, &user.Update{ 72 | ID: updateUser.ID, 73 | FirstName: updateUser.FirstName, 74 | LastName: updateUser.LastName, 75 | Mobile: updateUser.Mobile, 76 | Phone: updateUser.Phone, 77 | Address: updateUser.Address, 78 | }) 79 | if err != nil { 80 | apperr.Response(c, err) 81 | return 82 | } 83 | c.JSON(http.StatusOK, userUpdate) 84 | } 85 | 86 | func (u *User) delete(c *gin.Context) { 87 | id, err := request.ID(c) 88 | if err != nil { 89 | return 90 | } 91 | if err := u.svc.Delete(c, id); err != nil { 92 | apperr.Response(c, err) 93 | return 94 | } 95 | c.Status(http.StatusOK) 96 | } 97 | -------------------------------------------------------------------------------- /service/user_test.go: -------------------------------------------------------------------------------- 1 | package service_test 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | 10 | "github.com/gin-gonic/gin" 11 | "github.com/gogjango/gjango/apperr" 12 | "github.com/gogjango/gjango/mock" 13 | "github.com/gogjango/gjango/mock/mockdb" 14 | "github.com/gogjango/gjango/model" 15 | "github.com/gogjango/gjango/repository/user" 16 | "github.com/gogjango/gjango/service" 17 | "github.com/stretchr/testify/assert" 18 | ) 19 | 20 | func TestListUsers(t *testing.T) { 21 | type listResponse struct { 22 | Users []model.User `json:"users"` 23 | Page int `json:"page"` 24 | } 25 | cases := []struct { 26 | name string 27 | req string 28 | wantStatus int 29 | wantResp *listResponse 30 | userRepo *mockdb.User 31 | rbac *mock.RBAC 32 | auth *mock.Auth 33 | }{ 34 | { 35 | name: "Invalid request", 36 | req: `?limit=2222&page=-1`, 37 | wantStatus: http.StatusInternalServerError, 38 | }, 39 | { 40 | name: "Fail on query list", 41 | 42 | req: `?limit=100&page=1`, 43 | 44 | auth: &mock.Auth{ 45 | UserFn: func(c *gin.Context) *model.AuthUser { 46 | 47 | return &model.AuthUser{ 48 | 49 | ID: 1, 50 | 51 | CompanyID: 2, 52 | 53 | LocationID: 3, 54 | 55 | Role: model.UserRole, 56 | Email: "john@mail.com", 57 | } 58 | }}, 59 | wantStatus: http.StatusForbidden, 60 | }, 61 | { 62 | name: "Success", 63 | 64 | req: `?limit=100&page=1`, 65 | 66 | auth: &mock.Auth{ 67 | UserFn: func(c *gin.Context) *model.AuthUser { 68 | 69 | return &model.AuthUser{ 70 | 71 | ID: 1, 72 | 73 | CompanyID: 2, 74 | 75 | LocationID: 3, 76 | 77 | Role: model.SuperAdminRole, 78 | Email: "john@mail.com", 79 | } 80 | }}, 81 | userRepo: &mockdb.User{ 82 | ListFn: func(q *model.ListQuery, p *model.Pagination) ([]model.User, error) { 83 | 84 | if p.Limit == 100 && p.Offset == 100 { 85 | 86 | return []model.User{ 87 | 88 | { 89 | Base: model.Base{ 90 | CreatedAt: mock.TestTime(2001), 91 | UpdatedAt: mock.TestTime(2002), 92 | }, 93 | ID: 10, 94 | FirstName: "John", 95 | 96 | LastName: "Doe", 97 | 98 | Email: "john@mail.com", 99 | 100 | CompanyID: 2, 101 | 102 | LocationID: 3, 103 | 104 | Role: &model.Role{ 105 | ID: 1, 106 | 107 | AccessLevel: 1, 108 | 109 | Name: "SUPER_ADMIN", 110 | }, 111 | }, 112 | { 113 | Base: model.Base{ 114 | CreatedAt: mock.TestTime(2004), 115 | UpdatedAt: mock.TestTime(2005), 116 | }, 117 | ID: 11, 118 | FirstName: "Joanna", 119 | 120 | LastName: "Dye", 121 | 122 | Email: "joanna@mail.com", 123 | 124 | CompanyID: 1, 125 | 126 | LocationID: 2, 127 | 128 | Role: &model.Role{ 129 | ID: 2, 130 | 131 | AccessLevel: 2, 132 | 133 | Name: "ADMIN", 134 | }, 135 | }, 136 | }, nil 137 | 138 | } 139 | return nil, apperr.DB 140 | 141 | }, 142 | }, 143 | wantStatus: http.StatusOK, 144 | wantResp: &listResponse{ 145 | Users: []model.User{ 146 | { 147 | Base: model.Base{ 148 | CreatedAt: mock.TestTime(2001), 149 | UpdatedAt: mock.TestTime(2002), 150 | }, 151 | ID: 10, 152 | FirstName: "John", 153 | 154 | LastName: "Doe", 155 | 156 | Email: "john@mail.com", 157 | 158 | CompanyID: 2, 159 | 160 | LocationID: 3, 161 | 162 | Role: &model.Role{ 163 | ID: 1, 164 | 165 | AccessLevel: 1, 166 | 167 | Name: "SUPER_ADMIN", 168 | }, 169 | }, 170 | { 171 | Base: model.Base{ 172 | CreatedAt: mock.TestTime(2004), 173 | UpdatedAt: mock.TestTime(2005), 174 | }, 175 | ID: 11, 176 | FirstName: "Joanna", 177 | 178 | LastName: "Dye", 179 | 180 | Email: "joanna@mail.com", 181 | 182 | CompanyID: 1, 183 | 184 | LocationID: 2, 185 | 186 | Role: &model.Role{ 187 | ID: 2, 188 | 189 | AccessLevel: 2, 190 | 191 | Name: "ADMIN", 192 | }, 193 | }, 194 | }, Page: 1}, 195 | }, 196 | } 197 | gin.SetMode(gin.TestMode) 198 | 199 | for _, tt := range cases { 200 | t.Run(tt.name, func(t *testing.T) { 201 | r := gin.New() 202 | rg := r.Group("/v1") 203 | userService := user.NewUserService(tt.userRepo, tt.auth, tt.rbac) 204 | service.UserRouter(userService, rg) 205 | ts := httptest.NewServer(r) 206 | defer ts.Close() 207 | path := ts.URL + "/v1/users" + tt.req 208 | res, err := http.Get(path) 209 | if err != nil { 210 | t.Fatal(err) 211 | } 212 | defer res.Body.Close() 213 | if tt.wantResp != nil { 214 | response := new(listResponse) 215 | if err := json.NewDecoder(res.Body).Decode(response); err != nil { 216 | t.Fatal(err) 217 | } 218 | assert.Equal(t, tt.wantResp, response) 219 | } 220 | assert.Equal(t, tt.wantStatus, res.StatusCode) 221 | }) 222 | } 223 | } 224 | 225 | func TestViewUser(t *testing.T) { 226 | cases := []struct { 227 | name string 228 | req string 229 | wantStatus int 230 | wantResp *model.User 231 | udb *mockdb.User 232 | rbac *mock.RBAC 233 | auth *mock.Auth 234 | }{ 235 | { 236 | name: "Invalid request", 237 | req: `a`, 238 | wantStatus: http.StatusBadRequest, 239 | }, 240 | { 241 | name: "Fail on RBAC", 242 | req: `1`, 243 | rbac: &mock.RBAC{ 244 | EnforceUserFn: func(*gin.Context, int) bool { 245 | return false 246 | }, 247 | }, 248 | wantStatus: http.StatusForbidden, 249 | }, 250 | { 251 | name: "Success", 252 | req: `1`, 253 | rbac: &mock.RBAC{ 254 | EnforceUserFn: func(*gin.Context, int) bool { 255 | return true 256 | }, 257 | }, 258 | udb: &mockdb.User{ 259 | ViewFn: func(id int) (*model.User, error) { 260 | return &model.User{ 261 | Base: model.Base{ 262 | CreatedAt: mock.TestTime(2000), 263 | UpdatedAt: mock.TestTime(2000), 264 | }, 265 | ID: 1, 266 | FirstName: "John", 267 | LastName: "Doe", 268 | Username: "JohnDoe", 269 | }, nil 270 | }, 271 | }, 272 | wantStatus: http.StatusOK, 273 | wantResp: &model.User{ 274 | Base: model.Base{ 275 | CreatedAt: mock.TestTime(2000), 276 | UpdatedAt: mock.TestTime(2000), 277 | }, 278 | ID: 1, 279 | FirstName: "John", 280 | LastName: "Doe", 281 | Username: "JohnDoe", 282 | }, 283 | }, 284 | } 285 | gin.SetMode(gin.TestMode) 286 | 287 | for _, tt := range cases { 288 | t.Run(tt.name, func(t *testing.T) { 289 | r := gin.New() 290 | rg := r.Group("/v1") 291 | userService := user.NewUserService(tt.udb, tt.auth, tt.rbac) 292 | service.UserRouter(userService, rg) 293 | ts := httptest.NewServer(r) 294 | defer ts.Close() 295 | path := ts.URL + "/v1/users/" + tt.req 296 | res, err := http.Get(path) 297 | if err != nil { 298 | t.Fatal(err) 299 | } 300 | defer res.Body.Close() 301 | if tt.wantResp != nil { 302 | response := new(model.User) 303 | if err := json.NewDecoder(res.Body).Decode(response); err != nil { 304 | t.Fatal(err) 305 | } 306 | assert.Equal(t, tt.wantResp, response) 307 | } 308 | assert.Equal(t, tt.wantStatus, res.StatusCode) 309 | }) 310 | } 311 | } 312 | 313 | func TestUpdateUser(t *testing.T) { 314 | cases := []struct { 315 | name string 316 | req string 317 | id string 318 | wantStatus int 319 | wantResp *model.User 320 | udb *mockdb.User 321 | rbac *mock.RBAC 322 | auth *mock.Auth 323 | }{ 324 | { 325 | name: "Invalid request", 326 | id: `a`, 327 | wantStatus: http.StatusBadRequest, 328 | }, 329 | { 330 | name: "Fail on RBAC", 331 | id: `1`, 332 | req: `{"first_name":"jj","last_name":"okocha","mobile":"123456","phone":"321321","address":"home"}`, 333 | rbac: &mock.RBAC{ 334 | EnforceUserFn: func(*gin.Context, int) bool { 335 | return false 336 | }, 337 | }, 338 | wantStatus: http.StatusForbidden, 339 | }, 340 | { 341 | name: "Success", 342 | id: `1`, 343 | req: `{"first_name":"jj","last_name":"okocha","phone":"321321","address":"home"}`, 344 | rbac: &mock.RBAC{ 345 | EnforceUserFn: func(*gin.Context, int) bool { 346 | return true 347 | }, 348 | }, 349 | udb: &mockdb.User{ 350 | ViewFn: func(id int) (*model.User, error) { 351 | return &model.User{ 352 | Base: model.Base{ 353 | CreatedAt: mock.TestTime(2000), 354 | UpdatedAt: mock.TestTime(2000), 355 | }, 356 | ID: 1, 357 | FirstName: "John", 358 | LastName: "Doe", 359 | Username: "JohnDoe", 360 | Address: "Work", 361 | Mobile: "332223", 362 | }, nil 363 | }, 364 | UpdateFn: func(usr *model.User) (*model.User, error) { 365 | usr.UpdatedAt = mock.TestTime(2010) 366 | usr.Mobile = "991991" 367 | return usr, nil 368 | }, 369 | }, 370 | wantStatus: http.StatusOK, 371 | wantResp: &model.User{ 372 | Base: model.Base{ 373 | CreatedAt: mock.TestTime(2000), 374 | UpdatedAt: mock.TestTime(2010), 375 | }, 376 | ID: 1, 377 | FirstName: "jj", 378 | LastName: "okocha", 379 | Username: "JohnDoe", 380 | Address: "home", 381 | Mobile: "991991", 382 | }, 383 | }, 384 | } 385 | gin.SetMode(gin.TestMode) 386 | client := http.Client{} 387 | 388 | for _, tt := range cases { 389 | t.Run(tt.name, func(t *testing.T) { 390 | r := gin.New() 391 | rg := r.Group("/v1") 392 | userService := user.NewUserService(tt.udb, tt.auth, tt.rbac) 393 | service.UserRouter(userService, rg) 394 | ts := httptest.NewServer(r) 395 | defer ts.Close() 396 | path := ts.URL + "/v1/users/" + tt.id 397 | req, _ := http.NewRequest("PATCH", path, bytes.NewBufferString(tt.req)) 398 | res, err := client.Do(req) 399 | if err != nil { 400 | t.Fatal(err) 401 | } 402 | defer res.Body.Close() 403 | if tt.wantResp != nil { 404 | response := new(model.User) 405 | if err := json.NewDecoder(res.Body).Decode(response); err != nil { 406 | t.Fatal(err) 407 | } 408 | assert.Equal(t, tt.wantResp, response) 409 | } 410 | assert.Equal(t, tt.wantStatus, res.StatusCode) 411 | }) 412 | } 413 | } 414 | 415 | func TestDeleteUser(t *testing.T) { 416 | cases := []struct { 417 | name string 418 | id string 419 | wantStatus int 420 | udb *mockdb.User 421 | rbac *mock.RBAC 422 | auth *mock.Auth 423 | }{ 424 | { 425 | name: "Invalid request", 426 | id: `a`, 427 | wantStatus: http.StatusBadRequest, 428 | }, 429 | { 430 | name: "Fail on RBAC", 431 | id: `1`, 432 | udb: &mockdb.User{ 433 | ViewFn: func(id int) (*model.User, error) { 434 | return &model.User{ 435 | Role: &model.Role{ 436 | AccessLevel: model.CompanyAdminRole, 437 | }, 438 | }, nil 439 | }, 440 | }, 441 | rbac: &mock.RBAC{ 442 | IsLowerRoleFn: func(*gin.Context, model.AccessRole) bool { 443 | return false 444 | }, 445 | }, 446 | wantStatus: http.StatusForbidden, 447 | }, 448 | { 449 | name: "Success", 450 | id: `1`, 451 | udb: &mockdb.User{ 452 | ViewFn: func(id int) (*model.User, error) { 453 | return &model.User{ 454 | Role: &model.Role{ 455 | AccessLevel: model.CompanyAdminRole, 456 | }, 457 | }, nil 458 | }, 459 | DeleteFn: func(*model.User) error { 460 | return nil 461 | }, 462 | }, 463 | rbac: &mock.RBAC{ 464 | IsLowerRoleFn: func(*gin.Context, model.AccessRole) bool { 465 | return true 466 | }, 467 | }, 468 | wantStatus: http.StatusOK, 469 | }, 470 | } 471 | gin.SetMode(gin.TestMode) 472 | client := http.Client{} 473 | 474 | for _, tt := range cases { 475 | t.Run(tt.name, func(t *testing.T) { 476 | r := gin.New() 477 | rg := r.Group("/v1") 478 | userService := user.NewUserService(tt.udb, tt.auth, tt.rbac) 479 | service.UserRouter(userService, rg) 480 | ts := httptest.NewServer(r) 481 | defer ts.Close() 482 | path := ts.URL + "/v1/users/" + tt.id 483 | req, _ := http.NewRequest("DELETE", path, nil) 484 | res, err := client.Do(req) 485 | if err != nil { 486 | t.Fatal(err) 487 | } 488 | defer res.Body.Close() 489 | assert.Equal(t, tt.wantStatus, res.StatusCode) 490 | }) 491 | } 492 | } 493 | -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | case "$1" in 4 | -s | --short) 5 | case "$2" in 6 | -c | --coverage) echo "Run only unit tests (with coverage)" 7 | go test -v -coverprofile c.out -short ./... 8 | go tool cover -html=c.out 9 | ;; 10 | *) echo "Run only unit tests" 11 | go test -v -short ./... 12 | ;; 13 | esac 14 | ;; 15 | -i | --integration) echo "Run only integration tests" 16 | go test -v -run Integration ./... 17 | ;; 18 | *) echo "Run all tests (with coverage)" 19 | go test -coverprofile c.out ./... 20 | go tool cover -html=c.out 21 | ;; 22 | esac -------------------------------------------------------------------------------- /testhelper/testhelper.go: -------------------------------------------------------------------------------- 1 | package testhelper 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | ) 7 | 8 | // GetFreePort asks the kernel for a free open port that is ready to use. 9 | func GetFreePort(host string, preferredPort uint32) (int, error) { 10 | address := host + ":" + fmt.Sprint(preferredPort) 11 | addr, err := net.ResolveTCPAddr("tcp", address) 12 | if err != nil { 13 | return 0, err 14 | } 15 | 16 | l, err := net.ListenTCP("tcp", addr) 17 | if err != nil { 18 | return 0, err 19 | } 20 | defer l.Close() 21 | return l.Addr().(*net.TCPAddr).Port, nil 22 | } 23 | 24 | // AllocatePort returns a port that is available, given host and a preferred port 25 | // if none of the preferred ports are available, it will keep searching by adding 1 to the port number 26 | func AllocatePort(host string, preferredPort uint32) uint32 { 27 | preferredPortStr := fmt.Sprint(preferredPort) 28 | allocatedPort, err := GetFreePort(host, preferredPort) 29 | for err != nil { 30 | preferredPort = preferredPort + 1 31 | allocatedPort, err = GetFreePort(host, preferredPort) 32 | if err != nil { 33 | fmt.Println("Failed to connect to", preferredPortStr) 34 | } 35 | } 36 | fmt.Println("Allocated port", allocatedPort) 37 | return uint32(allocatedPort) 38 | } 39 | --------------------------------------------------------------------------------