diff --git a/.env.dev.sample b/.env.dev.sample new file mode 100755 index 0000000..0827072 --- /dev/null +++ b/.env.dev.sample @@ -0,0 +1,26 @@ +FLASK_APP=project/__init__.py +FLASK_ENV=development +APP_NAME='Flask API' +DATABASE_URL=postgresql://db_dev:db_password@db:5432/db_dev +DATABASE_TEST_URL=postgresql://db_test:db_password@db:5432/db_test +SQL_HOST=db +SQL_PORT=5432 +DATABASE=postgres +APP_FOLDER=/usr/src/app +APP_SETTINGS=project.config.DevelopmentConfig +SECRET_KEY=0000000000000000000000000000000000000000000000000000000 +CELERY_BROKER_URL=amqp://rabbit-user:rabbit-password@celery-broker:5672/ +CELERY_RESULT_BACKEND=rpc:// +CELERY_BROKER_TEST_URL=amqp://rabbit-user:rabbit-password@celery-broker:5672/ +GITHUB_CLIENT_ID=GITHUB_CLIENT_ID +GITHUB_CLIENT_SECRET=GITHUB_CLIENT_SECRET +FACEBOOK_CLIENT_ID=FACEBOOK_CLIENT_ID +FACEBOOK_CLIENT_SECRET=FACEBOOK_CLIENT_SECRET +OAUTHLIB_INSECURE_TRANSPORT=1 +MAIL_SERVER=MAIL_SERVER +MAIL_PORT=MAIL_PORT +MAIL_USERNAME=MAIL_USERNAME +MAIL_PASSWORD=MAIL_PASSWORD +MAIL_DEFAULT_SENDER=MAIL_DEFAULT_SENDER +MAIL_USE_TLS=True +MAIL_USE_SSL=False \ No newline at end of file diff --git a/.env.prod.db.sample b/.env.prod.db.sample new file mode 100755 index 0000000..5aca96b --- /dev/null +++ b/.env.prod.db.sample @@ -0,0 +1,5 @@ +POSTGRES_USER=db_user +POSTGRES_PASSWORD=db-password +POSTGRES_DB=db-prod +RABBITMQ_DEFAULT_USER=rabbit-user +RABBITMQ_DEFAULT_PASS=rabbit-password \ No newline at end of file diff --git a/.env.prod.sample b/.env.prod.sample new file mode 100755 index 0000000..25252ef --- /dev/null +++ b/.env.prod.sample @@ -0,0 +1,24 @@ +FLASK_APP=project/__init__.py +FLASK_ENV=development +APP_NAME='Flask API' +DATABASE_URL=postgresql://db_user:db_password@db:5432/db_dev +SQL_HOST=db +SQL_PORT=5432 +DATABASE=postgres +APP_FOLDER=/home/app/web +APP_SETTINGS=project.config.ProductionConfig +SECRET_KEY=0000000000000000000000000000000000000000000000000000000 +CELERY_BROKER_URL=amqp://rabbit-user:rabbit-password@celery-broker:5672/ +CELERY_RESULT_BACKEND=rpc:// +GITHUB_CLIENT_ID=GITHUB_CLIENT_ID +GITHUB_CLIENT_SECRET=GITHUB_CLIENT_SECRET +FACEBOOK_CLIENT_ID=FACEBOOK_CLIENT_ID +FACEBOOK_CLIENT_SECRET=FACEBOOK_CLIENT_SECRET +OAUTHLIB_INSECURE_TRANSPORT=1 +MAIL_SERVER=MAIL_SERVER +MAIL_PORT=MAIL_PORT +MAIL_USERNAME=MAIL_USERNAME +MAIL_PASSWORD=MAIL_PASSWORD +MAIL_DEFAULT_SENDER=MAIL_DEFAULT_SENDER +MAIL_USE_TLS=True +MAIL_USE_SSL=False \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..c12f143 --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# ide +*.pyc +__pycache +.vscode +.idea +.pytest* + +# mac +*~ +.DS_Store +.svn +.cvs +*.bak +*.swp +Thumbs.db + +# env configs +.env.dev +.env.prod +.env.prod.db + +# logs +./services/web/logs +*.log + +# custom debug for pycharm +debug* + +# virtualenv +env + +# cov +htmlcov +.coverage \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..65eb9e0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Muhammad Arsal Asif + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100755 index 0000000..d6c2701 --- /dev/null +++ b/README.md @@ -0,0 +1,232 @@ + +# Flask REST API Boilerplate + +A better boilerplate for RESTful APIs using Flask. + +If you have ever used Flask to build REST APIs, you'd know how cumbersome it can get. This repository aims to change that. + +This repository: + +1. Aims to fix common pain points with building REST APIs with Flask. +2. Uses pluggable views, blueprints, decorators and pydantic to modularize application and avoid repetition commonly associated with CRUD calls (DRY principle). [See how fast and easy it is to write APIs using this boilerplate](#guide-for-extending-this-boilerplate). +3. Doesn't use any of the Flask-RESTFul (Latest release: 2014), Flask-Restless (Latest release: 2016), or any similar spin-offs of Flask which eventually died out. Instead it relies only on core Flask. This has a huge advantage of always having the latest version of Flask for your application. + + +#### Tech Stack: + + - **Web framework:** Flask + - **ORM:** SQLAlchemy + - **Database:** PostgresSQL + - **Parsing/Validation:** Pydantic + - **Containerization:** Docker + - **Async-Task Queue:** Celery + - **Message-Broker:** RabbitMQ + - **WSGI Server:** Gunicorn + - **Reverse Proxy Server:** NGINX + - **Documentation:** Swagger-UI + +#### Features: + +* Containerized Docker build +* Separate docker services for database (**db**), message-broker (**celery-broker**), API (**web**), and documentation (**swagger-ui**). +* Ability to run this API with a different database, or broker, or documentation service. As easy as editing docker-compose and .env files. +* Separate environments and configs for Development, Testing, and Production. +* RESTful API documentation via Swagger and visualization with Swagger UI. +* Easy to write different API versions. +* Authentication via JWT. +* Email Verification. +* OAuth for Github and Facebook. +* Parsing and Validation via Pydantic. +* Database entities integrated with SQLAlchemy. +* Tests covering each of the REST API services, with code coverage. + +## Contents + +* [Get Started](#get-started) +* [Testing and Coverage](#testing-and-coverage) +* [RESTful endpoints](#restful-endpoints) +* [Guide for extending this Boilerplate](#guide-for-extending-this-boilerplate) + +## Get Started + +#### Requirements + +* Docker +* Docker Compose +* Docker Machine +* Other dependencies are listed in `requirements.txt` and are installed automatically. + +Get docker: https://docs.docker.com/get-docker/ + +Clone all the project from this repository and move to repository folder. + +Rename `.env.dev.sample` file to `.env.dev`. All environment variables are set from this file. + +Make sure you set the following environment variables: + + GITHUB_CLIENT_ID + GITHUB_CLIENT_SECRET + FACEBOOK_CLIENT_ID + FACEBOOK_CLIENT_SECRET + MAIL_SERVER + MAIL_PORT + MAIL_USERNAME + MAIL_PASSWORD + MAIL_DEFAULT_SENDER + +Build the images and run the containers. +```bash +docker-compose up --build +``` +or if you want to run it in detached (background) mode: +```bash +docker-compose up -d --build +``` +Make sure all containers are running: +```bash +docker-compose ps +``` +```bash +web +db +broker +docs +``` +Check swagger API documentation through http://localhost:8000. +API is available under http://localhost:5000/v1. +You can change the default ports in `docker-compose.yml` file. + +#### Database commands + +Create all development db tables: + +```docker +docker-compose exec web python manage.py create_db +``` +Recreate all development db tables: + +```docker +docker-compose exec web python manage.py recreate_db +``` +Populate seed data into db: + +```docker +docker-compose exec web python manage.py seed_db +``` +Want to reset everything? +```docker +docker-compose down -v +docker-compose up --build +``` + +## Testing and Coverage + +Finally test that everything works by executing the following curl command that tries to logged in using a default user created in the seed_db command: (default admin **email:** admin@arsal.me, **password:** password). + +```bash +curl -X POST "http://0.0.0.0:5000/v1/auth/login" -H "accept: application/json" -H "Content-Type: application/json" -d "{\"email\":\"admin@arsal.me\",\"password\":\"password\"}" +``` +> You should get a response like this: +```bash +{ + "auth_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE1MzAzODEzMTUsImlhdCI6MTUyNzc4OTMxNSwic3ViIjoxfQ.Dzf017g5Qf9Mi24AH-0X3womGW2koTY3c3cCO5p1djE", + "message": "Successfully logged in." +} +``` + +Run tests using: +```docker +docker-compose exec web python manage.py test +``` +> You should see an output like this: +```bash +... +... +Ensure encoding auth token works ... ok +test_model_user_passwords_are_random (test_model_user.TestUserModel) +Ensure passwords are randomly hashed ... ok +test_user_get (test_user_user.TestUserBlueprint) +Ensure get single user behaves correctly. ... ok + +---------------------------------------------------------------------- +Ran 80 tests in 13.335s + +OK +``` + +Run coverage using: +```docker +docker-compose exec web python manage.py cov +``` + + +## RESTful endpoints +Different roles: USER, ADMIN. + +### Authentication +| Endpoint | HTTP Method | Result | +|:---|:---:|---| +| `/auth/register` | `POST` | Registers a new user | +| `/auth/login` | `POST` | Login the user | +| `/auth/logout` | `GET` | User logout | +| `/auth/status` | `GET` | Returns the logged in user's status | +| `/auth/password_recovery` | `POST` | Creates a password_recovery_hash and sends email to user | +| `/auth/password_reset` | `PUT` | Reset user password | +| `/auth/password_change` | `PUT` | Changes user password | +> Endpoints implementation can be found under [/project/api/v1/auth/core.py](./services/web/project/api/v1/auth/core.py). + +### Social Auth +| Endpoint | HTTP Method | Result | +|:---|:---:|---| +| `/auth/facebook/login` | `GET` | Redirects user to Facebook to authenticate and returns API access token upon success. Works for registration and login. | +| `/auth/github/login` | `GET` | Redirects user to Github to authenticate and returns API access token upon success. Works for registration and login. | +> Endpoints implementation can be found under [/project/api/v1/auth/social.py](./services/web/project/api/v1/auth/social.py). + +### Email Verification +|Endpoint| HTTP Method | Result | +|:---|:---:|---| +| `/email_verification` | `PUT` | Creates a email_token_hash and sends email with token to user | +| `/email_verification/` | `GET` | Verifies email and sets email verified date | +> Endpoints implementation can be found under [/project/api/v1/auth/email_verification.py](./services/web/project/api/v1/auth/email_verification.py). + +### Users +**Requires role:** ADMIN + +| Endpoint | HTTP Method | Result | +|:---|:---:|---| +| `/users` | `POST` | Adds a new user | +| `/users` | `GET` | Gets all users | +| `/users/{user_id}` | `GET` | Gets the given user | +| `/users/{user_id}` | `PUT` | Updates the given user | +| `/users/{user_id}` | `DELETE` | Deletes the given user | +> Endpoints implementation can be found under [/project/api/v1/admin/users.py](./services/web/project/api/v1/admin/users.py). + +### User +**Requires role:** USER + +| Endpoint | HTTP Method | Result | +|:---|:---:|---| +| `/user` | `GET` | Get user info | +> Endpoints implementation can be found under [/project/api/v1/user/user.py](./services/web/project/api/v1/user/user.py). + + +For detailed documentation including request/response data, please check the Swagger-UI at http://localhost:8000. + +## Guide for extending this Boilerplate +Extending this boilerplate is very simple. +**Example:** You need to add a new API called *items* which lets normal users CRUD on their items. +1. Create `item` database model in [project/models](./services/web/project/models/). +2. Create `items.py` in [api/v1/user/](./services/web/project/api/v1/user/) folder. + 1. See [this](./services/web/project/api/v1/admin/users.py) as an example of how-to. + 2. Create an ItemsAPI class and extend this class from BaseAPI and MethodView classes. + 3. Overwrite the CRUD methods inherited from BaseAPI. In most cases, you'll just need to use a one-liner to call the base class method with your validation model, and db class. +3. Create `items.py` in [api/v1/validations/user/](./services/web/project/api/v1/validations/user/) folder. + 1. See [this](./services/web/project/api/v1/validations/admin/users.py) as an example of how-to. + 2. You will need knowledge of [pydantic](https://pydantic-docs.helpmanual.io/) to write validation models. +4. Create a new test file `test_user_items.py` (convention for this project) in [services/web/tests](./services/web/tests) and write your tests. + +**In a nutshell**, your request data is forwarded to BaseAPI, and for POST/PUT methods, you provide validation classes which map to attributes directly in database models. Any fields you provide in your validation classes (which already exist in DB models), are automatically mapped. Moreover, the data is also validated for its type, as well as any custom validators that you define. + +For admins, you will follow the same procedure, but instead use the admin folder under api/v1 and api/v1/validations. + +Feel free to nudge me if you need help. I'll also improve this writeup pretty soon. diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100755 index 0000000..5f57fb8 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,50 @@ +version: '3' + +services: + web: + container_name: web + build: + context: ./services/web + dockerfile: Dockerfile.prod + command: gunicorn --bind 0.0.0.0:5000 manage:app + volumes: + - static_volume:/home/app/web/project/static + - media_volume:/home/app/web/project/media + expose: + - 5000 + env_file: + - ./.env.prod + depends_on: + - db + - broker + broker: + container_name: broker + image: rabbitmq:3.7.14-management + db: + container_name: db + image: postgres:12-alpine + volumes: + - postgres_data:/var/lib/postgresql/data/ + env_file: + - ./.env.prod.db + docs: + container_name: docs + image: swaggerapi/swagger-ui:3.28.0 + build: ./services/swagger + ports: + - 8000:8080 + nginx: + container_name: nginx + build: ./services/nginx + volumes: + - static_volume:/home/app/web/project/static + - media_volume:/home/app/web/project/media + ports: + - 1337:80 + depends_on: + - web + +volumes: + postgres_data: + static_volume: + media_volume: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100755 index 0000000..6e5cf0a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,41 @@ +version: '3' + +services: + web: + container_name: web + build: ./services/web + command: python manage.py run -h 0.0.0.0 + volumes: + - ./services/web/:/usr/src/app/ + ports: + - 5000:5000 + - 5555:5555 + env_file: + - ./.env.dev + depends_on: + - db + - broker + db: + container_name: db + image: postgres:12-alpine + volumes: + - postgres_data:/var/lib/postgresql/data/ + - ./services/web/database:/docker-entrypoint-initdb.d + environment: + - POSTGRES_USER=db_user + - POSTGRES_PASSWORD=db_password + - POSTGRES_MULTIPLE_DATABASES=db_dev,db_test + broker: + container_name: broker + image: rabbitmq:3.7.14-management + environment: + - RABBITMQ_DEFAULT_USER=rabbit-user + - RABBITMQ_DEFAULT_PASS=rabbit-password + docs: + container_name: docs + image: swaggerapi/swagger-ui:3.28.0 + build: ./services/docs + ports: + - 8000:8080 +volumes: + postgres_data: diff --git a/services/docs/Dockerfile b/services/docs/Dockerfile new file mode 100644 index 0000000..7db7208 --- /dev/null +++ b/services/docs/Dockerfile @@ -0,0 +1,5 @@ +FROM swaggerapi/swagger-ui + +COPY swagger.yml /docs/swagger.yml + +ENV SWAGGER_JSON "/docs/swagger.yml" \ No newline at end of file diff --git a/services/docs/swagger.yml b/services/docs/swagger.yml new file mode 100644 index 0000000..c24a125 --- /dev/null +++ b/services/docs/swagger.yml @@ -0,0 +1,575 @@ +openapi: 3.0.0 +servers: + - url: http://0.0.0.0:5000/v1 + description: Development server +info: + description: | + Flask API - Documentation + version: 1.0.0 + title: Flask API + termsOfService: 'http://swagger.io/terms/' + contact: + email: arsalasif@users.noreply.github.com + license: + name: Apache 2.0 + url: 'http://www.apache.org/licenses/LICENSE-2.0.html' +tags: + - name: Users + description: Role = ADMIN + - name: Auth + description: Registration and Login + - name: SocialAuth + description: Authentication via OAuth2 + - name: Email + description: Email verification +paths: + /users: + post: + description: 'Add user' + tags: + - Users + parameters: + - $ref: '#/components/parameters/acceptHeaderParam' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UsersPost' + responses: + '200': + description: 'User was added!' + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + '400': + description: 'User with that username or email already exists.
or
Email, Username, Name and Password must be provided.' + get: + description: Get all users + tags: + - Users + parameters: + - in: query + name: page + schema: + type: integer + required: false + description: Page number to fetch + - in: query + name: per_page + schema: + type: integer + required: false + description: Number of items to fetch per page + - in: query + name: filter + schema: + type: string + required: false + description: Provides filtering capabilities. Can use table field names with comparison operators such as < > != == as well as operators 'and', 'or', etc. Usage example -> filter=(id < 3). + - in: query + name: order_by + schema: + type: string + required: false + description: Default ordering is descending order of created time. This parameter can be used to sort manually. Usage example -> order_by=(id desc). + - $ref: '#/components/parameters/acceptHeaderParam' + responses: + '200': + description: Paginated list of users + content: + application/json: + schema: + $ref: '#/components/schemas/UserArray' + /users/{user_id}: + get: + description: Get single user details + tags: + - Users + parameters: + - $ref: '#/components/parameters/acceptHeaderParam' + - name: user_id + in: path + description: ID of user to fetch + required: true + schema: + type: integer + format: int64 + minimum: 1 + responses: + '200': + description: Get single user details + content: + application/json: + schema: + $ref: '#/components/schemas/User' + '404': + description: 'User does not exist.' + put: + description: Updates User + tags: + - Users + parameters: + - $ref: '#/components/parameters/acceptHeaderParam' + - name: user_id + in: path + description: ID of user to update + required: true + schema: + type: integer + format: int64 + minimum: 1 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UsersPost' + responses: + '200': + description: User was updated! + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + '404': + description: User does not exist. + delete: + description: Deletes a user based on a user ID + tags: + - Users + parameters: + - $ref: '#/components/parameters/acceptHeaderParam' + - name: user_id + in: path + description: ID of user to delete + required: true + schema: + type: integer + format: int64 + minimum: 1 + responses: + '200': + description: User was deleted! + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + '404': + description: User does not exist. + /auth/register: + post: + description: New user registration + tags: + - Auth + parameters: + - $ref: '#/components/parameters/acceptHeaderParam' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AuthRegister' + responses: + '200': + description: Successfully registered. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponseAuth' + '400': + description: 'User with that username already exists.
or
User with that email already exists.' + security: [] + /auth/login: + post: + description: User login + tags: + - Auth + parameters: + - $ref: '#/components/parameters/acceptHeaderParam' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UserLogin' + responses: + '200': + description: Successfully logged in + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponseAuth' + '404': + description: 'User does not exist.' + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + security: [] + /auth/logout: + get: + description: Logs a user out + tags: + - Auth + parameters: + - $ref: '#/components/parameters/acceptHeaderParam' + responses: + '200': + description: Successfully logged out. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + /auth/status: + get: + description: Returns the logged in user's status + tags: + - Auth + parameters: + - $ref: '#/components/parameters/acceptHeaderParam' + responses: + '200': + description: User object + content: + application/json: + schema: + $ref: '#/components/schemas/User' + /auth/password_recovery: + post: + description: Creates a password_recovery_hash and sends email to user + tags: + - Auth + parameters: + - $ref: '#/components/parameters/acceptHeaderParam' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PasswordRecovery' + responses: + '200': + description: Password recovery email sent. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + '400': + description: Email does not exist. + security: [] + /auth/password_reset: + put: + description: Reset user password + tags: + - Auth + parameters: + - $ref: '#/components/parameters/acceptHeaderParam' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PasswordReset' + responses: + '200': + description: Successfully reset password. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + security: [] + /auth/password_change: + put: + description: Changes user password + tags: + - Auth + parameters: + - $ref: '#/components/parameters/acceptHeaderParam' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PasswordChange' + responses: + '200': + description: Successfully changed password. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + '400': + description: Invalid current password. Please try again. + /auth/facebook/login: + get: + description: Redirects user to Facebook for OAuth2 login. + tags: + - SocialAuth + parameters: + - $ref: '#/components/parameters/acceptHeaderParam' + responses: + '200': + description: Success! Logged in with Facebook. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponseAuth' + '400': + description: Something went wrong with Facebook. Try again. + security: [] + /auth/github/login: + get: + description: Redirects user to Github for OAuth2 login. + tags: + - SocialAuth + parameters: + - $ref: '#/components/parameters/acceptHeaderParam' + responses: + '200': + description: Success! Logged in with Github. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponseAuth' + '400': + description: Something went wrong with Github. Try again. + security: [] + /email_verification: + put: + description: Creates a email_token_hash and sends email with token to user + tags: + - Email + parameters: + - $ref: '#/components/parameters/acceptHeaderParam' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PasswordReset' + responses: + '200': + description: Successfully sent email with email verification. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + /email_verification/{token}: + get: + description: Verifies email with given token + tags: + - Email + parameters: + - name: token + in: path + description: JWT token received in email for verification + required: true + schema: + type: string + responses: + '200': + description: Successful email verification. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + '400': + description: Verification link expired. +externalDocs: + description: Find out more about Swagger + url: 'http://swagger.io' +components: + parameters: + acceptHeaderParam: + in: header + name: Accept + required: true + schema: + type: string + default: application/json + description: Accept HTTP header default value (application/json) + schemas: + User: + type: object + properties: + id: + type: integer + format: int64 + email: + type: string + username: + type: string + name: + type: string + active: + type: boolean + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + role: + type: integer + format: int64 + role_name: + type: string + social_type: + type: string + email_validation_date: + type: string + format: date-time + UserArray: + type: array + items: + $ref: '#/components/schemas/User' + AuthRegister: + type: object + required: + - username + - email + - name + - password + properties: + email: + type: string + username: + type: string + password: + type: string + name: + type: string + UsersPost: + type: object + required: + - username + - email + - name + - password + properties: + email: + type: string + username: + type: string + password: + type: string + name: + type: string + role: + type: integer + format: int64 + UsersPut: + type: object + properties: + email: + type: string + username: + type: string + password: + type: string + name: + type: string + role: + type: integer + format: int64 + UserLogin: + type: object + required: + - email + - password + properties: + email: + type: string + password: + type: string + PasswordRecovery: + type: object + required: + - email + properties: + email: + type: string + PasswordReset: + type: object + required: + - token + - password + properties: + token: + type: string + password: + type: string + PasswordChange: + type: object + required: + - current_password + - new_password + properties: + current_password: + type: string + new_password: + type: string + Token: + type: object + required: + - token + properties: + token: + type: string + ApiResponse: + type: object + properties: + status: + type: integer + format: int32 + message: + type: string + required: + - status + - message + ApiResponseAuth: + allOf: + - $ref: '#/components/schemas/ApiResponse' + - type: object + properties: + auth_token: + type: string + required: + - auth_token + # Schema for error response body + Error: + type: object + properties: + code: + type: string + message: + type: string + required: + - code + - message + requestBodies: + UserArray: + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/AuthRegister' + description: List of user object + required: true + responses: + ServerError: + description: Something went wrong + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT +security: + - bearerAuth: [] diff --git a/services/nginx/Dockerfile b/services/nginx/Dockerfile new file mode 100755 index 0000000..5f9269f --- /dev/null +++ b/services/nginx/Dockerfile @@ -0,0 +1,4 @@ +FROM nginx:1.19-alpine + +RUN rm /etc/nginx/conf.d/default.conf +COPY nginx.conf /etc/nginx/conf.d diff --git a/services/nginx/nginx.conf b/services/nginx/nginx.conf new file mode 100755 index 0000000..7138dc1 --- /dev/null +++ b/services/nginx/nginx.conf @@ -0,0 +1,24 @@ +upstream flask-api { + server web:5000; +} + +server { + + listen 80; + + location / { + proxy_pass http://flask-api; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $host; + proxy_redirect off; + } + + location /static/ { + alias /home/app/web/project/static/; + } + + location /media/ { + alias /home/app/web/project/media/; + } + +} diff --git a/services/web/Dockerfile b/services/web/Dockerfile new file mode 100755 index 0000000..a62a863 --- /dev/null +++ b/services/web/Dockerfile @@ -0,0 +1,23 @@ +# pull official base image +FROM python:3.8.1-slim-buster + +# set work directory +WORKDIR /usr/src/app + +# set environment variables +ENV PYTHONDONTWRITEBYTECODE 1 +ENV PYTHONUNBUFFERED 1 + +# install system dependencies +RUN apt-get update && apt-get install -y netcat + +# install dependencies +RUN pip install --upgrade pip +COPY ./requirements.txt /usr/src/app/requirements.txt +RUN pip install -r requirements.txt + +# copy project +COPY . /usr/src/app/ + +# run entrypoint.sh +ENTRYPOINT ["/usr/src/app/entrypoint.sh"] \ No newline at end of file diff --git a/services/web/Dockerfile.prod.sample b/services/web/Dockerfile.prod.sample new file mode 100755 index 0000000..6f3fe3b --- /dev/null +++ b/services/web/Dockerfile.prod.sample @@ -0,0 +1,70 @@ +########### +# BUILDER # +########### + +# pull official base image +FROM python:3.8.1-slim-buster as builder + +# set work directory +WORKDIR /usr/src/app + +# set environment variables +ENV PYTHONDONTWRITEBYTECODE 1 +ENV PYTHONUNBUFFERED 1 + +# install system dependencies +RUN apt-get update && \ + apt-get install -y --no-install-recommends gcc + +# lint +RUN pip install --upgrade pip +RUN pip install flake8 +COPY . /usr/src/app/ +RUN flake8 --ignore=E501,F401 . + +# install python dependencies +COPY ./requirements.txt . +RUN pip wheel --no-cache-dir --no-deps --wheel-dir /usr/src/app/wheels -r requirements.txt + + +######### +# FINAL # +######### + +# pull official base image +FROM python:3.8.1-slim-buster + +# create directory for the app user +RUN mkdir -p /home/app + +# create the app user +RUN addgroup --system app && adduser --system --group app + + +# create the appropriate directories +ENV HOME=/home/app +ENV APP_HOME=/home/app/web +RUN mkdir $APP_HOME +WORKDIR $APP_HOME + +# install dependencies +RUN apt-get update && apt-get install -y --no-install-recommends netcat +COPY --from=builder /usr/src/app/wheels /wheels +COPY --from=builder /usr/src/app/requirements.txt . +RUN pip install --upgrade pip +RUN pip install --no-cache /wheels/* + +# copy entrypoint-prod.sh +COPY ./entrypoint.prod.sh $APP_HOME + +# copy project +COPY . $APP_HOME + +# chown all the files to the app user +RUN chown -R app:app $APP_HOME + +# change to the app user +USER app + +# run entrypoint.prod.sh +ENTRYPOINT ["/home/app/web/entrypoint.prod.sh"] diff --git a/services/web/database/create-multiple-postgresql-databases.sh b/services/web/database/create-multiple-postgresql-databases.sh new file mode 100644 index 0000000..7b20666 --- /dev/null +++ b/services/web/database/create-multiple-postgresql-databases.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +set -e +set -u + +function create_user_and_database() { + local database=$1 + echo " Creating user and database '$database'" + psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" <<-EOSQL + CREATE USER $database WITH PASSWORD '$POSTGRES_PASSWORD'; + CREATE DATABASE $database; + GRANT ALL PRIVILEGES ON DATABASE $database TO $database; + EOSQL +} + +if [ $POSTGRES_MULTIPLE_DATABASES ]; then + echo "Multiple database creation requested: $POSTGRES_MULTIPLE_DATABASES" + for db in $(echo $POSTGRES_MULTIPLE_DATABASES | tr ',' ' '); do + create_user_and_database $db + done + echo "Multiple databases created" +fi \ No newline at end of file diff --git a/services/web/entrypoint.prod.sh b/services/web/entrypoint.prod.sh new file mode 100755 index 0000000..37fa201 --- /dev/null +++ b/services/web/entrypoint.prod.sh @@ -0,0 +1,14 @@ +#!/bin/sh + +if [ "$DATABASE" = "postgres" ] +then + echo "Waiting for postgres..." + + while ! nc -z $SQL_HOST $SQL_PORT; do + sleep 0.1 + done + + echo "PostgreSQL started" +fi + +exec "$@" diff --git a/services/web/entrypoint.sh b/services/web/entrypoint.sh new file mode 100755 index 0000000..37fa201 --- /dev/null +++ b/services/web/entrypoint.sh @@ -0,0 +1,14 @@ +#!/bin/sh + +if [ "$DATABASE" = "postgres" ] +then + echo "Waiting for postgres..." + + while ! nc -z $SQL_HOST $SQL_PORT; do + sleep 0.1 + done + + echo "PostgreSQL started" +fi + +exec "$@" diff --git a/services/web/logs/.gitkeep b/services/web/logs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/services/web/manage.py b/services/web/manage.py new file mode 100755 index 0000000..43166e9 --- /dev/null +++ b/services/web/manage.py @@ -0,0 +1,93 @@ +import coverage + +COV = coverage.coverage( + branch=True, + include='project/*', + omit=[ + 'project/static/*' + ] +) +COV.start() + +from flask.cli import FlaskGroup +import click + +from project import app, db +from project.models.user import User +from project.models.user import UserRole +from project.models.group import Group +from project.models.user_group_association import UserGroupAssociation + +import unittest +cli = FlaskGroup(app) + + +@cli.command("recreate_db") +def recreate_db(): + """ + Recreates the database + """ + db.reflect() + db.drop_all() + db.create_all() + db.session.commit() + +@cli.command("create_db") +def create_db(): + """ + Create the database + """ + db.drop_all() + db.create_all() + db.session.commit() + +@cli.command("seed_db") +def seed_db(): + """ + Seed the database + """ + group = Group(name="Group Name") + db.session.add(group) + user1 = User(username='admin', name="Admin", email='admin@arsal.me', password="password", role=UserRole.ADMIN) + user2 = User(username='user', name="User", email='teamleader@arsal.me', password="password") + db.session.add(user1) + db.session.add(user2) + user_group_association1 = UserGroupAssociation(user=user1, group=group) + db.session.add(user_group_association1) + user_group_association2 = UserGroupAssociation(user=user2, group=group) + db.session.add(user_group_association2) + db.session.commit() + +@cli.command() +@click.argument('file', required=False) +def test(file): + """ + Run the tests without code coverage + """ + pattern = 'test_*.py' if file is None else file + tests = unittest.TestLoader().discover('tests', pattern=pattern) + result = unittest.TextTestRunner(verbosity=2).run(tests) + if result.wasSuccessful(): + return 0 + return 1 + +@cli.command() +def cov(): + """ + Run the unit tests with coverage + """ + tests = unittest.TestLoader().discover('tests') + result = unittest.TextTestRunner(verbosity=2).run(tests) + if result.wasSuccessful(): + COV.stop() + COV.save() + print('Coverage Summary:') + COV.report() + COV.html_report() + COV.erase() + return 0 + return 1 + + +if __name__ == "__main__": + cli() diff --git a/services/web/project/__init__.py b/services/web/project/__init__.py new file mode 100755 index 0000000..7ee5a1f --- /dev/null +++ b/services/web/project/__init__.py @@ -0,0 +1,78 @@ +import os +from flask import Config +from flask_sqlalchemy import SQLAlchemy +from flask_bcrypt import Bcrypt +from flask_mail import Mail +from celery import Celery +from oauthlib.oauth2 import WebApplicationClient +from .api.common.base_definitions import BaseFlask + +# flask config +conf = Config(root_path=os.path.abspath(os.path.dirname(__file__))) +conf.from_object(os.getenv('APP_SETTINGS')) + +# instantiate the extensions +db = SQLAlchemy() +bcrypt = Bcrypt() +mail = Mail() + + +def create_app(): + # instantiate the app + app = BaseFlask(__name__) + # set up extensions + db.init_app(app) + bcrypt.init_app(app) + mail.init_app(app) + + # register blueprints + from .api.v1.auth import auth_blueprints + from .api.v1.user import user_blueprints + from .api.v1.admin import admin_blueprints + + blueprints = [*auth_blueprints, *user_blueprints, *admin_blueprints] + for blueprint in blueprints: + app.register_blueprint(blueprint, url_prefix='/v1') + + app.github_client = WebApplicationClient(app.config['GITHUB_CLIENT_ID']) + app.facebook_client = WebApplicationClient(app.config['FACEBOOK_CLIENT_ID']) + return app + + +def make_celery(app): + app = app or create_app() + # add include=['project.tasks.weather_tasks'] + celery = Celery(__name__, broker=app.config['CELERY_BROKER_URL'], include=['project.tasks.mail_tasks'], + backend=app.config['CELERY_RESULT_BACKEND']) + celery.conf.update(app.config) + TaskBase = celery.Task + + class ContextTask(TaskBase): + abstract = True + + def __call__(self, *args, **kwargs): + with app.app_context(): + return TaskBase.__call__(self, *args, **kwargs) + + celery.Task = ContextTask + return celery + + +app = create_app() + +# register error handlers +from werkzeug.exceptions import HTTPException + +from .api.common.utils import exceptions +from .api.common import error_handlers + +app.register_error_handler(exceptions.InvalidPayloadException, error_handlers.handle_exception) +app.register_error_handler(exceptions.BadRequestException, error_handlers.handle_exception) +app.register_error_handler(exceptions.UnauthorizedException, error_handlers.handle_exception) +app.register_error_handler(exceptions.ForbiddenException, error_handlers.handle_exception) +app.register_error_handler(exceptions.NotFoundException, error_handlers.handle_exception) +app.register_error_handler(exceptions.ServerErrorException, error_handlers.handle_exception) +app.register_error_handler(Exception, error_handlers.handle_general_exception) +app.register_error_handler(HTTPException, error_handlers.handle_werkzeug_exception) + +celery = make_celery(app) diff --git a/services/web/project/api/__init__.py b/services/web/project/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/web/project/api/common/__init__.py b/services/web/project/api/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/web/project/api/common/base_definitions.py b/services/web/project/api/common/base_definitions.py new file mode 100644 index 0000000..a6f6791 --- /dev/null +++ b/services/web/project/api/common/base_definitions.py @@ -0,0 +1,80 @@ +import datetime, os, logging +from flask import Flask, jsonify, Response +from flask.json import JSONEncoder +from flask_cors import CORS + + +class BaseJSONEncoder(JSONEncoder): + """ + Encodes JSON + """ + def default(self, obj): + try: + if isinstance(obj, datetime.date): + return obj.isoformat() + iterable = iter(obj) + except TypeError: + pass + else: + return list(iterable) + return JSONEncoder.default(self, obj) + +class BaseResponse(Response): + """ + Base response + """ + default_mimetype = 'application/json' + + @classmethod + def force_type(cls, rv, environ=None): + if isinstance(rv, dict): + rv = jsonify(rv) + return super(BaseResponse, cls).force_type(rv, environ) + + +class BaseFlask(Flask): + """ + Construct base application module + """ + response_class = BaseResponse + json_encoder = BaseJSONEncoder # set up custom encoder to handle date as ISO8601 format + + def __init__( + self, + import_name + ): + Flask.__init__( + self, + import_name, + static_folder='./static', + template_folder='./templates' + ) + # set config + app_settings = os.getenv('APP_SETTINGS') + self.config.from_object(app_settings) + + ## log for werkzeug + # import functools + # from flask._compat import reraise + # + # def my_log_exception(exc_info, original_log_exception=None): + # original_log_exception(exc_info) + # exc_type, exc, tb = exc_info + # # re-raise for werkzeug + # reraise(exc_type, exc, tb) + # + # self.log_exception = functools.partial(my_log_exception, original_log_exception=self.log_exception) + + ## + + # configure logging + from logging.handlers import RotatingFileHandler + file_handler = RotatingFileHandler(self.config['LOGGING_LOCATION'], 'a', 1 * 1024 * 1024, 10) + file_handler.setFormatter( + logging.Formatter(self.config['LOGGING_FORMAT'])) + self.logger.setLevel(logging.DEBUG) + file_handler.setLevel(logging.DEBUG) + self.logger.addHandler(file_handler) + + # enable CORS + CORS(self) diff --git a/services/web/project/api/common/error_handlers.py b/services/web/project/api/common/error_handlers.py new file mode 100644 index 0000000..2f74a5d --- /dev/null +++ b/services/web/project/api/common/error_handlers.py @@ -0,0 +1,49 @@ +from flask import jsonify, json, current_app +from werkzeug.exceptions import NotFound, Unauthorized, Forbidden, MethodNotAllowed, NotImplemented, BadRequest +from ...api.common.utils.exceptions import APIException, ServerErrorException, NotFoundException, UnauthorizedException, \ + ForbiddenException, MethodNotAllowedException, NotImplementedException, BadRequestException + +def handle_exception(error: APIException): + """ + Handle specific raised API Exception + """ + # current_app.logger.debug(error.message) + response = jsonify(error.to_dict()) + response.status_code = error.status_code + return response + +def handle_general_exception(e): + """ + Handle general exceptions + """ + # current_app.logger.debug(e) + return handle_exception(ServerErrorException()) + +def handle_werkzeug_exception(e): + """ + Handle Werkzeug Exceptions: return JSON instead of HTML for HTTP errors. + """ + # current_app.logger.debug(e) + if isinstance(e, NotFound): + return handle_exception(NotFoundException(message=e.description)) + if isinstance(e, Unauthorized): + return handle_exception(UnauthorizedException(message=e.description)) + if isinstance(e, Forbidden): + return handle_exception(ForbiddenException(message=e.description)) + if isinstance(e, MethodNotAllowed): + return handle_exception(MethodNotAllowedException(message=e.description)) + if isinstance(e, NotImplemented): + return handle_exception(NotImplementedException(message=e.description)) + if isinstance(e, BadRequest): + return handle_exception(BadRequestException(message=e.description)) + + # start with the correct headers and status code from the error + response = e.get_response() + # replace the body with JSON + response.data = json.dumps({ + "code": e.code, + "name": e.name, + "description": e.description, + }) + response.content_type = "application/json" + return response diff --git a/services/web/project/api/common/utils/__init__.py b/services/web/project/api/common/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/web/project/api/common/utils/constants.py b/services/web/project/api/common/utils/constants.py new file mode 100644 index 0000000..17e44dd --- /dev/null +++ b/services/web/project/api/common/utils/constants.py @@ -0,0 +1,7 @@ +class Constants: + """ + Useful constants + """ + + class HttpHeaders: + AUTHORIZATION = 'Authorization' diff --git a/services/web/project/api/common/utils/decorators.py b/services/web/project/api/common/utils/decorators.py new file mode 100644 index 0000000..d2cce4d --- /dev/null +++ b/services/web/project/api/common/utils/decorators.py @@ -0,0 +1,47 @@ +from flask import request, current_app +from functools import wraps + +from ....api.common.utils.exceptions import UnauthorizedException, ForbiddenException +from ....models.user import User, UserRole + + +def privileges(role): + """ + Decorator to verify user privileges based on active status and role + (implicit authentication, no need to use authenticate decorator with privileges) + """ + def actual_decorator(f): + @wraps(f) + def decorated_function(*args, **kwargs): + auth_header = request.headers.get('Authorization') + if not auth_header: + raise UnauthorizedException() + auth_token = auth_header.split(" ")[1] + user_id = User.decode_auth_token(auth_token) + user = User.get(user_id) + if not user or not user.active: + raise UnauthorizedException() + user_role = UserRole(user.role) + if not bool(user_role & role): + raise ForbiddenException() + return f(user_id, *args, **kwargs) + return decorated_function + return actual_decorator + + +def authenticate(f): + """ + Decorator to authenticate users based on token + """ + @wraps(f) + def decorated_function(*args, **kwargs): + auth_header = request.headers.get('Authorization') + if not auth_header: + raise UnauthorizedException() + auth_token = auth_header.split(" ")[1] + user_id = User.decode_auth_token(auth_token) + user = User.get(user_id) + if not user or not user.active: + raise UnauthorizedException() + return f(user_id, *args, **kwargs) + return decorated_function diff --git a/services/web/project/api/common/utils/exceptions.py b/services/web/project/api/common/utils/exceptions.py new file mode 100644 index 0000000..9f9c027 --- /dev/null +++ b/services/web/project/api/common/utils/exceptions.py @@ -0,0 +1,108 @@ +from pydantic import ValidationError + + +class APIException(Exception): + """ + Base API Exception + """ + + def __init__(self, message, status_code, payload, name): + super().__init__() + self.message = message + self.status_code = status_code + self.payload = payload + self.name = name + + def to_dict(self): + rv = dict(self.payload or ()) + rv['code'] = self.status_code + rv['name'] = self.name + rv['message'] = self.message + return rv + + +class InvalidPayloadException(APIException): + """ + 400 Invalid Payload Exception + """ + + def __init__(self, message: str = 'Invalid Payload', payload=None, name='Invalid Payload'): + super().__init__(message=message, status_code=400, payload=payload, name=name) + + +class ValidationException(InvalidPayloadException): + """ + 400 Invalid Payload Exception with Validation Errors + """ + + def __init__(self, e: ValidationError, message: str = 'Validation Error'): + payload = dict({'message': 'validation errors', + 'errors': [{'field': error['loc'][0], 'message': error['msg']} + for error in e.errors()]}) + super().__init__(message=message, payload=payload) + + +class BadRequestException(APIException): + """ + 400 Bad Request Exception + """ + + def __init__(self, message: str = 'Bad Request', payload=None, name='Bad Request'): + super().__init__(message=message, status_code=400, payload=payload, name=name) + + +class UnauthorizedException(APIException): + """ + 401 Unauthorized Exception + """ + + def __init__(self, message: str = 'Not Authorized to perform this action', payload=None, name='Unauthorized'): + super().__init__(message=message, status_code=401, payload=payload, name=name) + + +class ForbiddenException(APIException): + """ + 403 Forbidden Exception + """ + + def __init__(self, message: str = 'Forbidden', payload=None, name='Forbidden'): + super().__init__(message=message, status_code=403, payload=payload, name=name) + + +class NotFoundException(APIException): + """ + 404 Not Found Exception + """ + + def __init__(self, message: str = 'The requested URL was not found on the server.', + payload=None, name: str = 'Not Found'): + super().__init__(message=message, status_code=404, payload=payload, name=name) + + +class ServerErrorException(APIException): + """ + 500 Internal Server Error Exception + """ + + def __init__(self, message: str = 'Something went wrong', payload=None, name='Internal Server Error'): + super().__init__(message=message, status_code=500, payload=payload, name=name) + + +class NotImplementedException(APIException): + """ + 501 Not Implemented Exception + """ + + def __init__(self, message: str = 'The method is not implemented for the requested URL.', payload=None, + name='Not Implemented'): + super().__init__(message=message, status_code=501, payload=payload, name=name) + + +class MethodNotAllowedException(APIException): + """ + 405 Method Not Allowed + """ + + def __init__(self, message: str = 'The method is not allowed for the requested URL.', payload=None, + name='Method Not Allowed'): + super().__init__(message=message, status_code=405, payload=payload, name=name) diff --git a/services/web/project/api/common/utils/helpers.py b/services/web/project/api/common/utils/helpers.py new file mode 100644 index 0000000..e7e4be4 --- /dev/null +++ b/services/web/project/api/common/utils/helpers.py @@ -0,0 +1,60 @@ +from contextlib import contextmanager +from datetime import datetime +from flask import current_app, request +from sqlalchemy import exc +from urllib import parse + +from ....api.common.utils.exceptions import ServerErrorException, InvalidPayloadException, NotFoundException, \ + ValidationException + + +@contextmanager +def session_scope(session): + """ + Provide a transactional scope around a series of operations. + """ + try: + yield session + session.commit() + except (InvalidPayloadException, NotFoundException, ValidationException) as e: + session.rollback() + raise e + except exc.SQLAlchemyError: + session.rollback() + raise ServerErrorException() + + +def get_date(date: str) -> datetime: + """ + Convert str to date in a specific format + """ + return datetime.strptime(date, current_app.config.get('DATE_FORMAT')) + + +def get_date_str(date: datetime) -> str: + """ + Convert date to str in a specific format + """ + return date.strftime(current_app.config.get('DATE_FORMAT')) + + +def get_query_from_text(query: str) -> str: + """ + Get query from text from request + Query must have (, ) brackets, this method essentially removes the trailing brackets + """ + query = request.args.get(query, default='', type=str) + query = query[1:-1] + return parse.unquote(query) + + +def register_api(blueprint, view, endpoint, url, pk='id', pk_type='int'): + """ + Register CRUD endpoints for API + """ + view_func = view.as_view(endpoint) + blueprint.add_url_rule(url, defaults={pk: None}, + view_func=view_func, methods=['GET', ]) + blueprint.add_url_rule(url, view_func=view_func, methods=['POST', ]) + blueprint.add_url_rule(f'{url}<{pk_type}:{pk}>', view_func=view_func, + methods=['GET', 'PUT', 'DELETE']) diff --git a/services/web/project/api/common/utils/mails.py b/services/web/project/api/common/utils/mails.py new file mode 100644 index 0000000..58845e3 --- /dev/null +++ b/services/web/project/api/common/utils/mails.py @@ -0,0 +1,40 @@ +from flask import render_template, request, url_for, current_app + +from ....tasks.mail_tasks import send_async_registration_email, send_async_password_recovery_email,\ + send_async_email_verification_email + + +def send_password_recovery_email(user, token): + """ + Send email for password recovery + """ + href = request.base_url + '/' + token + app_name = current_app.config.get('APP_NAME') + send_async_password_recovery_email.delay(subject=f'Password Recovery for {app_name}', + recipient=user.email, + text_body=render_template("auth/password_recovery_user.txt", user=user), + html_body=render_template("auth/password_recovery_user.html", user=user, href=href)) + + +def send_registration_email(user, token): + """ + Send email for registration + """ + href = request.url_root + url_for('email_verification.verify_email', token='')[1:] + token + app_name = current_app.config.get('APP_NAME') + send_async_registration_email.delay(subject=f'Welcome to {app_name}!', + recipient=user.email, + text_body=render_template("auth/welcome_new_user.txt", user=user), + html_body=render_template("auth/welcome_new_user.html", user=user, href=href)) + + +def send_email_verification_email(user, token): + """ + Send email for verification + """ + href = request.base_url + '/' + token + app_name = current_app.config.get('APP_NAME') + send_async_email_verification_email.delay(subject=f'Email confirmation for {app_name}', + recipient=user.email, + text_body=render_template("auth/email_verification_user.txt", user=user), + html_body=render_template("auth/email_verification_user.html", user=user, href=href)) diff --git a/services/web/project/api/v1/__init__.py b/services/web/project/api/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/web/project/api/v1/admin/__init__.py b/services/web/project/api/v1/admin/__init__.py new file mode 100644 index 0000000..b0a8d9d --- /dev/null +++ b/services/web/project/api/v1/admin/__init__.py @@ -0,0 +1,6 @@ +from .users import users_blueprint + +""" +Add your admin blueprints here +""" +admin_blueprints = [users_blueprint] diff --git a/services/web/project/api/v1/admin/users.py b/services/web/project/api/v1/admin/users.py new file mode 100644 index 0000000..13d3a85 --- /dev/null +++ b/services/web/project/api/v1/admin/users.py @@ -0,0 +1,37 @@ +from flask import Blueprint +from flask.views import MethodView +from flask_accept import accept + +from ..base import BaseAPI +from ....models.user import User, UserRole +from ...common.utils.helpers import register_api +from ...common.utils.decorators import privileges +from ..validations.admin.users import UsersPost, UsersPut + +users_blueprint = Blueprint('users', __name__) + + +class UsersAPI(BaseAPI, MethodView): + decorators = [accept('application/json'), privileges(role=UserRole.ADMIN)] + + def post(self, logged_in_user_id: int, **kwargs): + return super().post(logged_in_user_id, UsersPost, User) + + def get(self, logged_in_user_id: int, user_id: int = None, **kwargs): + if user_id is None: + return super().get(logged_in_user_id, User) + else: + return super().get_by_id(logged_in_user_id, user_id, User) + + def put(self, logged_in_user_id: int, user_id: int, **kwargs): + return super().put(logged_in_user_id, user_id, UsersPut, User) + + def delete(self, logged_in_user_id: int, user_id: int, **kwargs): + return super().delete(logged_in_user_id, user_id, User) + + +register_api(blueprint=users_blueprint, + view=UsersAPI, + endpoint='users_api', + url='/users/', + pk='user_id') diff --git a/services/web/project/api/v1/auth/__init__.py b/services/web/project/api/v1/auth/__init__.py new file mode 100644 index 0000000..e4f5012 --- /dev/null +++ b/services/web/project/api/v1/auth/__init__.py @@ -0,0 +1,10 @@ +from .core import auth_core_blueprint +from .social import auth_social_blueprint +from .email_verification import email_verification_blueprint + +""" +Add your auth blueprints here +""" +auth_blueprints = [auth_core_blueprint, + auth_social_blueprint, + email_verification_blueprint] diff --git a/services/web/project/api/v1/auth/core.py b/services/web/project/api/v1/auth/core.py new file mode 100644 index 0000000..791f780 --- /dev/null +++ b/services/web/project/api/v1/auth/core.py @@ -0,0 +1,175 @@ +from flask import request, current_app, jsonify, Blueprint +from flask_accept import accept +from sqlalchemy import exc +from pydantic import ValidationError + +from .... import bcrypt, db +from ....api.common.utils.exceptions import InvalidPayloadException, NotFoundException, \ + ServerErrorException, ValidationException +from ....api.common.utils.decorators import authenticate, privileges +from ....models.user import User, UserRole +from ....api.common.utils.helpers import session_scope +from ..validations.auth.core import UserRegister, UserLogin, PasswordChange, PasswordReset, PasswordRecovery + + +auth_core_blueprint = Blueprint('auth_core', __name__) + + +@auth_core_blueprint.route('/auth/register', methods=['POST']) +@accept('application/json') +def register_user(): + """ + New user registration + """ + # Get post data + post_data = request.get_json() + if not post_data: + raise InvalidPayloadException() + try: + data = UserRegister(**post_data) + except ValidationError as e: + raise ValidationException(e) + + # Check for existing user + try: + user = User(**data.dict()) + with session_scope(db.session) as session: + session.add(user) + + with session_scope(db.session) as session: + token = user.encode_email_token() + user.email_token_hash = bcrypt.generate_password_hash(token, current_app.config.get( + 'BCRYPT_LOG_ROUNDS')).decode() + + if not current_app.testing: + from ....api.common.utils.mails import send_registration_email + send_registration_email(user, token.decode()) + + # Generate auth token + auth_token = user.encode_auth_token() + return jsonify(message='Successfully registered.', auth_token=auth_token.decode()), 201 + except (exc.IntegrityError, Exception): + session.rollback() + raise ServerErrorException() + + +@auth_core_blueprint.route('/auth/login', methods=['POST']) +@accept('application/json') +def login_user(): + """ + User login + """ + post_data = request.get_json() + if not post_data: + raise InvalidPayloadException() + + try: + data = UserLogin(**post_data) + except ValidationError as e: + raise ValidationException(e) + + user = User.first_by(email=data.email) + if not user: + raise NotFoundException(message='User does not exist.') + + if bcrypt.check_password_hash(user.password, data.password): + auth_token = user.encode_auth_token() + return jsonify(message='Successfully logged in.', auth_token=auth_token.decode()) + else: + raise InvalidPayloadException(message="Incorrect password.") + + +@auth_core_blueprint.route('/auth/logout', methods=['GET']) +@accept('application/json') +@privileges(role=UserRole.USER | UserRole.ADMIN) +def logout_user(_): + """ + Logout user + """ + return jsonify(message='Successfully logged out.') + + +@auth_core_blueprint.route('/auth/status', methods=['GET']) +@accept('application/json') +@authenticate +def get_user_status(user_id: int): + """ + Get authentication status + """ + user = User.get(user_id) + return jsonify(email=user.email, username=user.username, name=user.name, active=user.active, + created_at=user.created_at) + + +@auth_core_blueprint.route('/auth/password_change', methods=['PUT']) +@accept('application/json') +@authenticate +def password_change(user_id: int): + """ + Changes user password when logged in + """ + post_data = request.get_json() + if not post_data: + raise InvalidPayloadException() + user = User.get(user_id) + try: + data = PasswordChange(user=user, **post_data) + except ValidationError as e: + raise ValidationException(e) + + with session_scope(db.session): + user.password = bcrypt.generate_password_hash(data.new_password, + current_app.config.get('BCRYPT_LOG_ROUNDS')).decode() + + return jsonify(message='Successfully changed password.') + + +@auth_core_blueprint.route('/auth/password_reset', methods=['PUT']) +@accept('application/json') +def password_reset(): + """ + Reset user password + """ + post_data = request.get_json() + if not post_data: + raise InvalidPayloadException() + + data = PasswordReset(**post_data) + user_id = User.decode_password_token(data.token) + user = User.get(user_id) + if not user or not user.token_hash or not bcrypt.check_password_hash(user.token_hash, data.token): + raise InvalidPayloadException('Invalid password reset token. Please try again.') + + with session_scope(db.session): + user.password = bcrypt.generate_password_hash(data.password, + current_app.config.get('BCRYPT_LOG_ROUNDS')).decode() + user.token_hash = None + return jsonify(message='Successfully reset password.') + + +@auth_core_blueprint.route('/auth/password_recovery', methods=['POST']) +@accept('application/json') +def password_recovery(): + """ + Creates a password_recovery_hash and sends email to user + """ + post_data = request.get_json() + if not post_data: + raise InvalidPayloadException() + + try: + data = PasswordRecovery(**post_data) + except ValidationError as e: + raise ValidationException(e) + + user = User.first_by(email=data.email) + if user: + token = user.encode_password_token() + with session_scope(db.session): + user.token_hash = bcrypt.generate_password_hash(token, current_app.config.get('BCRYPT_LOG_ROUNDS')).decode() + if not current_app.testing: + from project.api.common.utils.mails import send_password_recovery_email + send_password_recovery_email(user, token.decode()) # send recovery email + return jsonify(message='Password recovery email sent.') + else: + raise NotFoundException("Email does not exist.") diff --git a/services/web/project/api/v1/auth/email_verification.py b/services/web/project/api/v1/auth/email_verification.py new file mode 100644 index 0000000..3d58e76 --- /dev/null +++ b/services/web/project/api/v1/auth/email_verification.py @@ -0,0 +1,70 @@ +from datetime import datetime +from flask import current_app, jsonify, Blueprint +from flask_accept import accept + +from .... import db, bcrypt +from ....api.common.utils.exceptions import NotFoundException, InvalidPayloadException +from ....api.common.utils.decorators import authenticate +from ....models.user import User +from ....api.common.utils.helpers import session_scope + +email_verification_blueprint = Blueprint('email_verification', __name__) + + +@email_verification_blueprint.route('/email_verification/', methods=['GET']) +@accept('application/json') +@authenticate +def email_verification(user_id: int): + """ + Creates a email_token_hash and sends email with token to user + """ + # fetch the user data + user = User.first(User.id == user_id) + if user: + token = user.encode_email_token() + with session_scope(db.session): + user.email_token_hash = bcrypt.generate_password_hash(token, current_app.config.get('BCRYPT_LOG_ROUNDS')).decode() + if not current_app.testing: + from project.api.common.utils.mails import send_email_verification_email + send_email_verification_email(user, token.decode()) + return jsonify(message='sent email with verification token') + + +@email_verification_blueprint.route('/email_verification/', methods=['GET']) +def verify_email(token): + """ + Verifies email with given token + """ + user_id = User.decode_email_token(token) + user = User.get(user_id) + if not user: + raise NotFoundException(message='user does not exist') + if not user.email_token_hash: + raise InvalidPayloadException(message='verification link expired') + + bcrypt.check_password_hash(user.email_token_hash, token) + + with session_scope(db.session): + user.email_validation_date = datetime.utcnow() + user.active = True + user.email_token_hash = None + + return jsonify(message='email verified') + + +@email_verification_blueprint.route('/email_verification/resend', methods=['GET']) +@accept('application/json') +@authenticate +def resend_verification(user_id: int): + """ + Resend verification email + """ + user = User.first(User.id == user_id) + if user: + token = user.encode_email_token() + with session_scope(db.session) as session: + user.email_token_hash = bcrypt.generate_password_hash(token, current_app.config.get('BCRYPT_LOG_ROUNDS')).decode() + if not current_app.testing: + from project.api.common.utils.mails import send_registration_email + send_registration_email(user, token.decode()) + return jsonify(message="verification email resent") diff --git a/services/web/project/api/v1/auth/social.py b/services/web/project/api/v1/auth/social.py new file mode 100644 index 0000000..6ecc945 --- /dev/null +++ b/services/web/project/api/v1/auth/social.py @@ -0,0 +1,216 @@ +from flask import request, current_app, redirect, jsonify, make_response, Blueprint +from sqlalchemy import and_ +from flask_accept import accept +import requests +import json + +from .... import bcrypt, db +from ....models.user import User, SocialAuth +from ....api.common.utils.helpers import session_scope +from ....api.common.utils.exceptions import BadRequestException, InvalidPayloadException, NotFoundException +from ....api.common.utils.decorators import authenticate +from uuid import uuid4 + +auth_social_blueprint = Blueprint('auth_social', __name__) + + +@auth_social_blueprint.route('/auth/github/login', methods=['GET']) +def github_login(): + """ + Logs in user using GitHub redirect to callback + """ + authorization_endpoint = 'https://github.com/login/oauth/authorize' + request_uri = current_app.github_client.prepare_request_uri( + authorization_endpoint, + redirect_uri=request.base_url + '/callback', + scope=['user:email'] + ) + return redirect(request_uri) + + +@auth_social_blueprint.route('/auth/github/login/callback', methods=['GET']) +def github_login_callback(): + """ + Get access token with code and return the corresponding JWT + """ + code = request.args.get("code") + token_endpoint = 'https://github.com/login/oauth/access_token' + + # Prepare and send a request to get tokens + token_url, headers, body = current_app.github_client.prepare_token_request( + token_endpoint, + authorization_response=request.url, + redirect_url=request.base_url, + code=code + ) + headers['Accept'] = 'application/json' + token_response = requests.post( + token_url, + headers=headers, + data=body, + auth=(current_app.config['GITHUB_CLIENT_ID'], current_app.config['GITHUB_CLIENT_SECRET']) + ) + + # Parse the tokens! + current_app.github_client.parse_request_body_response(json.dumps(token_response.json())) + + user_endpoint = 'https://api.github.com/user' + uri, headers, body = current_app.github_client.add_token(user_endpoint) + github_response = requests.get(uri, headers=headers, data=body) + + # You want to make sure their email is verified. + # The user authenticated with GitHub, authorized our + # app, and now their email is verified through GitHub + + if 'login' in github_response.json(): + username = github_response.json()["login"] + social_id = str(github_response.json()["id"]) + name = github_response.json()["name"] + access_token = token_response.json()['access_token'] + email = github_response.json()["email"] + + if not email: + email_endpoint = 'https://api.github.com/user/emails' + uri, headers, body = current_app.github_client.add_token(email_endpoint) + email_response = requests.get(uri, headers=headers, data=body) + for github_emails in email_response.json(): + if github_emails['email'] and github_emails['primary'] is True: + email = github_emails['email'] + break + + return add_user_into_db(email, name, username, social_id, access_token, SocialAuth.GITHUB) + else: + raise BadRequestException(message=f'Something went wrong with {SocialAuth.GITHUB.value}. Try again.') + + +@auth_social_blueprint.route('/auth/facebook/login', methods=['GET']) +def facebook_login(): + """ + Logs in user using Facebook redirect to callback + """ + authorization_endpoint = 'https://www.facebook.com/v7.0/dialog/oauth' + request_uri = current_app.facebook_client.prepare_request_uri( + authorization_endpoint, + redirect_uri=request.base_url + '/callback', + scope=['email'], + state=str(uuid4()) + ) + return redirect(request_uri) + + +@auth_social_blueprint.route('/auth/facebook/login/callback', methods=['GET']) +def facebook_login_callback(): + """ + Get access token with code and return the corresponding JWT + """ + code = request.args.get("code") + token_endpoint = 'https://graph.facebook.com/oauth/access_token' + + # Prepare and send a request to get tokens + token_url, headers, body = current_app.facebook_client.prepare_token_request( + token_endpoint, + authorization_response=request.url, + redirect_url=request.base_url, + code=code + ) + headers['Accept'] = 'application/json' + token_response = requests.post( + token_url, + headers=headers, + data=body, + auth=(current_app.config['FACEBOOK_CLIENT_ID'], current_app.config['FACEBOOK_CLIENT_SECRET']) + ) + + # Parse the tokens! + current_app.facebook_client.parse_request_body_response(json.dumps(token_response.json())) + + user_endpoint = 'https://graph.facebook.com/v7.0/me?fields=id,email,name' + uri, headers, body = current_app.facebook_client.add_token(user_endpoint) + facebook_response = requests.get(uri, headers=headers, data=body) + + # You want to make sure their email is verified. + # The user authenticated with Facebook, authorized our + # app, and now their email is verified through Facebook + + if 'email' in facebook_response.json(): + username = facebook_response.json()["email"] + social_id = str(facebook_response.json()["id"]) + name = facebook_response.json()["name"] + access_token = token_response.json()['access_token'] + email = facebook_response.json()["email"] + + return add_user_into_db(email, name, username, social_id, access_token, SocialAuth.FACEBOOK) + else: + raise BadRequestException(message=f'Something went wrong with {SocialAuth.FACEBOOK.value}. Try again.') + + +def add_user_into_db(email: str, name: str, username: str, social_id: str, access_token: str, social_type: SocialAuth): + """ + Adds user into database + """ + user = User.first(and_(User.social_id == social_id, User.social_type == social_type.value)) + if not user: + # Not an existing user so get info, register and login + user = User.first(User.email == email) + code = 200 + with session_scope(db.session) as session: + if user: + user.social_access_token = access_token + user.social_id = social_id + user.social_type = social_type.value + else: + # Create the user and insert it into the database + user = User(email=email, + name=name, + username=username, + social_id=social_id, + social_type=social_type, + social_access_token=access_token) + session.add(user) + code = 201 + # generate auth token + auth_token = user.encode_auth_token() + return make_response(jsonify(message=f'registered with {social_type.value} and logged in', + auth_token=auth_token.decode()), code) + else: + auth_token = user.encode_auth_token() + with session_scope(db.session): + user.social_access_token = access_token + return make_response(jsonify(message=f'logged in with {social_type.value}', + auth_token=auth_token.decode())) + + +# TODO Review +@auth_social_blueprint.route('/auth/social/set_standalone_user', methods=['PUT']) +@accept('application/json') +@authenticate +def set_standalone_user(user_id: int): + """ + Changes user password when logged in + """ + post_data = request.get_json() + if not post_data: + raise InvalidPayloadException() + username = post_data.get('username') + pw_old = post_data.get('old_password') + pw_new = post_data.get('new_password') + if not username or not pw_old or not pw_new: + raise InvalidPayloadException() + + # fetch the user data + user = User.get(user_id) + if not user.social_type: + raise NotFoundException(message='Must be a social authenticated user login. Please try again.') + + # fetch the user data + user = User.get(user_id) + if not bcrypt.check_password_hash(user.password, pw_old): + raise NotFoundException(message='Incorrect old password. Please try again.') + + if not User.first(User.username == username): + with session_scope(db.session): + user.username = username + user.password = bcrypt.generate_password_hash(pw_new, current_app.config.get('BCRYPT_LOG_ROUNDS')).decode() + return jsonify(message='Successfully changed password.') + else: + raise InvalidPayloadException(message='Sorry. That username already exists, choose another username') diff --git a/services/web/project/api/v1/base.py b/services/web/project/api/v1/base.py new file mode 100644 index 0000000..bcf79fc --- /dev/null +++ b/services/web/project/api/v1/base.py @@ -0,0 +1,138 @@ +from flask import request, current_app, jsonify +from sqlalchemy import exc, text +from pydantic import BaseModel, ValidationError +from typing import Type + +from ...models.base import Base +from ... import db +from ..common.utils.exceptions import NotFoundException, InvalidPayloadException, BadRequestException, \ + ValidationException +from ..common.utils.helpers import get_query_from_text, session_scope + + +class BaseAPI: + def post(self, logged_in_user_id: int, + validator: Type[BaseModel], + entity: Type[Base], + post_data: dict = None): + """Standard POST call""" + post_data = dict(post_data or request.get_json()) + if not post_data: + raise InvalidPayloadException() + try: + with session_scope(db.session) as session: + try: + data = validator(logged_in_user_id=logged_in_user_id, **post_data) + except ValidationError as e: + raise ValidationException(e) + + model = entity(**data.dict()) + db.session.add(model) + return jsonify(message=f'{entity.__tablename__} was added'), 201 + except exc.SQLAlchemyError: + db.session.rollback() + raise InvalidPayloadException() + + def get_by_id(self, logged_in_user_id: int, + id_: int, + entity: Type[Base], + json_func: str = 'json'): + """Standard GET by Id call""" + model = entity.get(id_) + if not model: + raise NotFoundException(message=f'{entity.__tablename__} does not exist') + return jsonify(getattr(model, json_func)()) + + def get(self, logged_in_user_id: int, + entity: Type[Base], + json_func: str = 'json', + custom_filter=None): + """Standard GET call""" + page = request.args.get('page', 1, type=int) + per_page = request.args.get('per_page', current_app.config.get('POSTS_PER_PAGE'), type=int) + + query = get_query_from_text('filter') + order_by = get_query_from_text('order_by') + + try: + """ + Custom filter allows us to pass additional criterion, such as to limit visibility + An example is a normal user querying and only active entities should be returned, + custom filter limits the result to only active entities + """ + if custom_filter is not None: + models = entity.query.order_by(text(order_by), entity.created_at.desc()).filter(text(query), + custom_filter) + else: + models = entity.query.order_by(text(order_by), entity.created_at.desc()).filter(text(query)) + + models = models.paginate(page, per_page, False, max_per_page=current_app.config.get('MAX_PER_PAGE')) + + return jsonify({'page': models.page, + 'per_page': models.per_page, + 'number_of_pages': models.pages, + f'{entity.__tablename__}s': [getattr(model, json_func)() for model in models.items]}) + except exc.SQLAlchemyError: + raise BadRequestException() + + def put(self, logged_in_user_id: int, + id_: int, + validator: Type[BaseModel], + entity: Type[Base], + post_data: dict = None): + """Standard PUT call""" + post_data = dict(post_data or request.get_json()) + if not post_data: + raise InvalidPayloadException() + try: + with session_scope(db.session) as session: + model = entity.get(id_) + if not model: + raise NotFoundException(message=f'{entity.__tablename__} does not exist') + try: + data = validator(logged_in_user_id=logged_in_user_id, model=model, **post_data) + except ValidationError as e: + raise ValidationException(e) + + for key, value in data.dict().items(): + if value is not None: + setattr(model, key, value) + return jsonify(message=f'{entity.__tablename__} was updated') + except exc.SQLAlchemyError: + db.session.rollback() + raise InvalidPayloadException() + + def delete(self, logged_in_user_id: int, + id_: int, + entity: Type[Base]): + """Standard DELETE call""" + model = entity.get(id_) + if not model: + raise NotFoundException(message=f'{entity.__tablename__} does not exist') + try: + db.session.delete(model) + db.session.commit() + return jsonify(message=f'{entity.__tablename__} was deleted') + except exc.SQLAlchemyError: + db.session.rollback() + raise InvalidPayloadException() + + def delete_with_validation(self, logged_in_user_id: int, + id_: int, + validator: Type[BaseModel], + entity: Type[Base]): + """Standard DELETE call with validation, useful for endpoints where deletion is based on some restriction""" + try: + with session_scope(db.session) as session: + model = entity.get(id_) + if not model: + raise NotFoundException(message=f'{entity.__tablename__} does not exist') + try: + validator(logged_in_user_id=logged_in_user_id, model=model) + except ValidationError as e: + raise ValidationException(e) + db.session.delete(model) + return jsonify(message=f'{entity.__tablename__} was deleted') + except exc.SQLAlchemyError: + db.session.rollback() + raise InvalidPayloadException() \ No newline at end of file diff --git a/services/web/project/api/v1/user/__init__.py b/services/web/project/api/v1/user/__init__.py new file mode 100644 index 0000000..a7e55ee --- /dev/null +++ b/services/web/project/api/v1/user/__init__.py @@ -0,0 +1,6 @@ +from .user import user_blueprint + +""" +Add your user blueprints here +""" +user_blueprints = [user_blueprint] diff --git a/services/web/project/api/v1/user/user.py b/services/web/project/api/v1/user/user.py new file mode 100644 index 0000000..7161bf7 --- /dev/null +++ b/services/web/project/api/v1/user/user.py @@ -0,0 +1,39 @@ +from flask import jsonify, Blueprint +from flask_accept import accept +from flask.views import MethodView + +from ..base import BaseAPI +from ....models.user import User +from ...common.utils.helpers import register_api +from ...common.utils.exceptions import NotImplementedException +from ...common.utils.decorators import authenticate + +user_blueprint = Blueprint('user', __name__) + + +class UserAPI(BaseAPI, MethodView): + decorators = [accept('application/json'), authenticate] + + def post(self, logged_in_user_id: int, **kwargs): + raise NotImplementedException() + + def get(self, logged_in_user_id: int, user_id: int = None, **kwargs): + if user_id is None: + user = User.get(logged_in_user_id) + return jsonify(email=user.email, username=user.username, name=user.name, active=user.active, + created_at=user.created_at, social_id=user.social_id, social_type=user.social_type) + else: + raise NotImplementedException() + + def put(self, logged_in_user_id: int, user_id: int, **kwargs): + raise NotImplementedException() + + def delete(self, logged_in_user_id: int, user_id: int, **kwargs): + raise NotImplementedException() + + +register_api(blueprint=user_blueprint, + view=UserAPI, + endpoint='user_api', + url='/user/', + pk='user_id') diff --git a/services/web/project/api/v1/validations/admin/__init__.py b/services/web/project/api/v1/validations/admin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/web/project/api/v1/validations/admin/users.py b/services/web/project/api/v1/validations/admin/users.py new file mode 100644 index 0000000..333a72d --- /dev/null +++ b/services/web/project/api/v1/validations/admin/users.py @@ -0,0 +1,59 @@ +from flask import current_app +from pydantic import BaseModel, EmailStr, validator +from typing import Optional +from sqlalchemy import and_ + +from .....models.user import User, UserRole +from ..... import bcrypt + + +class UsersPost(BaseModel): + email: EmailStr + username: str + password: str + name: str + role: UserRole = UserRole.USER + + @validator('email') + def validate_email(cls, email): + if User.exists(User.email == email): + raise ValueError('email already exists') + return email + + @validator('username') + def validate_username(cls, username): + if User.exists(User.username == username): + raise ValueError('username already exists') + return username + + +class UsersPut(BaseModel): + model: User + email: Optional[EmailStr] + username: Optional[str] + password: Optional[str] + name: Optional[str] + role: Optional[UserRole] + active: Optional[bool] + + @validator('email') + def validate_email(cls, email, values): + if email and User.exists(and_(User.email == email, User.id != values['model'].id)): + raise ValueError('email already exists') + return email + + @validator('username') + def validate_username(cls, username, values): + if username and User.exists(and_(User.username == username, User.id != values['model'].id)): + raise ValueError('username already exists') + return username + + @validator('password') + def generate_password_hash(cls, password): + if password: + return bcrypt.generate_password_hash(password, + current_app.config.get('BCRYPT_LOG_ROUNDS')).decode() + return password + + class Config: + arbitrary_types_allowed = True diff --git a/services/web/project/api/v1/validations/auth/__init__.py b/services/web/project/api/v1/validations/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/web/project/api/v1/validations/auth/core.py b/services/web/project/api/v1/validations/auth/core.py new file mode 100644 index 0000000..11987ee --- /dev/null +++ b/services/web/project/api/v1/validations/auth/core.py @@ -0,0 +1,52 @@ +from pydantic import BaseModel, EmailStr, validator +from .....models.user import User +from ..... import bcrypt + + +class UserRegister(BaseModel): + email: EmailStr + username: str + password: str + name: str + active = False + + @validator('email') + def validate_email(cls, email): + if User.exists(User.email == email): + raise ValueError('email already exists') + return email + + @validator('username') + def validate_username(cls, username): + if User.exists(User.username == username): + raise ValueError('username already exists') + return username + + +class UserLogin(BaseModel): + email: EmailStr + password: str + + +class PasswordChange(BaseModel): + user: User + current_password: str + new_password: str + + @validator('current_password') + def validate_current_password(cls, current_password, values): + if not bcrypt.check_password_hash(values['user'].password, current_password): + raise ValueError('Invalid current password. Please try again.') + return current_password + + class Config: + arbitrary_types_allowed = True + + +class PasswordReset(BaseModel): + token: str + password: str + + +class PasswordRecovery(BaseModel): + email: EmailStr diff --git a/services/web/project/api/v1/validations/user/__init__.py b/services/web/project/api/v1/validations/user/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/web/project/config.py b/services/web/project/config.py new file mode 100755 index 0000000..f593860 --- /dev/null +++ b/services/web/project/config.py @@ -0,0 +1,98 @@ +import os +import logging + +basedir = os.path.abspath(os.path.dirname(__file__)) + +class BaseConfig: + """ + Base Configuration + """ + # Base + APP_NAME = os.environ.get('APP_NAME') + DEBUG = False + TESTING = False + LOGGING_FORMAT = '%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]' + LOGGING_LOCATION = 'logs/flask.log' + LOGGING_LEVEL = logging.DEBUG + SQLALCHEMY_TRACK_MODIFICATIONS = False + + # Security + SECRET_KEY = os.environ.get('SECRET_KEY') + BCRYPT_LOG_ROUNDS = 13 + TOKEN_EXPIRATION_DAYS = 30 + TOKEN_EXPIRATION_SECONDS = 0 + TOKEN_PASSWORD_EXPIRATION_DAYS = 1 + TOKEN_PASSWORD_EXPIRATION_SECONDS = 0 + TOKEN_EMAIL_EXPIRATION_DAYS = 1 + TOKEN_EMAIL_EXPIRATION_SECONDS = 0 + + # Mail Server + MAIL_SERVER = os.environ.get('MAIL_SERVER') + MAIL_PORT = os.environ.get('MAIL_PORT') + MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS').lower() == 'true' + MAIL_USE_SSL = os.environ.get('MAIL_USE_SSL').lower() == 'true' + MAIL_USERNAME = os.environ.get('MAIL_USERNAME') + MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD') + MAIL_DEFAULT_SENDER = os.environ.get('MAIL_DEFAULT_SENDER') + + # Folders + STATIC_FOLDER = f"{os.environ.get('APP_FOLDER')}/project/static" + MEDIA_FOLDER = f"{os.environ.get('APP_FOLDER')}/project/media" + + # Pagination + POSTS_PER_PAGE = 10 + MAX_PER_PAGE = 100 + DATE_FORMAT = '%m-%d-%Y, %H:%M:%S' + + # Social Authentication + GITHUB_CLIENT_ID = os.environ.get("GITHUB_CLIENT_ID", None) + GITHUB_CLIENT_SECRET = os.environ.get("GITHUB_CLIENT_SECRET", None) + FACEBOOK_CLIENT_ID = os.environ.get("FACEBOOK_CLIENT_ID", None) + FACEBOOK_CLIENT_SECRET = os.environ.get("FACEBOOK_CLIENT_SECRET", None) + +class DevelopmentConfig(BaseConfig): + """ + Development Configuration + """ + DEBUG = True + SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL") + CELERY_BROKER_URL = os.environ.get('CELERY_BROKER_URL') + CELERY_RESULT_BACKEND = os.environ.get('CELERY_RESULT_BACKEND') + BCRYPT_LOG_ROUNDS = 4 + +class TestingConfig(BaseConfig): + """ + Testing Configuration + """ + # Base + DEBUG = True + TESTING = True + TEST_LOGGING_LEVEL = logging.DEBUG + TEST_LOGGING_FORMAT = '%(asctime)s - %(name)s - %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]' + TEST_LOGGING_LOCATION = 'logs/flask_test.log' + + # Security + BCRYPT_LOG_ROUNDS = 4 + TOKEN_EXPIRATION_DAYS = 0 + TOKEN_EXPIRATION_SECONDS = 3 + TOKEN_PASSWORD_EXPIRATION_DAYS = 0 + TOKEN_PASSWORD_EXPIRATION_SECONDS = 2 + TOKEN_EMAIL_EXPIRATION_DAYS = 1 + TOKEN_EMAIL_EXPIRATION_SECONDS = 0 + MAIL_SUPPRESS_SEND = True + + # Config + SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_TEST_URL') + CELERY_BROKER_URL = os.environ.get('CELERY_BROKER_TEST_URL') + CELERY_RESULT_BACKEND = os.environ.get('CELERY_RESULT_BACKEND') + CELERY_TASK_ALWAYS_EAGER = True + + +class ProductionConfig(BaseConfig): + """ + Production Configuration + """ + DEBUG = False + SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL") + CELERY_BROKER_URL = os.environ.get('CELERY_BROKER_URL') + CELERY_RESULT_BACKEND = os.environ.get('CELERY_RESULT_BACKEND') diff --git a/services/web/project/models/__init__.py b/services/web/project/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/web/project/models/base.py b/services/web/project/models/base.py new file mode 100644 index 0000000..057575a --- /dev/null +++ b/services/web/project/models/base.py @@ -0,0 +1,62 @@ +from __future__ import annotations +from datetime import datetime +import json +from typing import Type + +from .. import db + + +class Base(db.Model): + """ + Base model + """ + __abstract__ = True + __tablename__ = "base" + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + created_at = db.Column(db.DateTime, nullable=False, default=datetime.now()) + updated_at = db.Column(db.DateTime, nullable=False, default=datetime.now(), onupdate=datetime.now()) + + def __init__(self, + created_at: datetime = datetime.now(), + updated_at: datetime = datetime.now()): + self.created_at = created_at + self.updated_at = updated_at + + @classmethod + def first_by(cls, **kwargs) -> Base: + """ + Get first entity that matches to criterion + """ + return cls.query.filter_by(**kwargs).first() + + @classmethod + def first(cls, *criterion) -> Base: + """ + Get first entity that matches to criterion + """ + return cls.query.filter(*criterion).first() + + @classmethod + def exists(cls, *criterion) -> bool: + """ + Check if entry with criterion exists + """ + return cls.query.filter(*criterion).scalar() + + @classmethod + def get(cls, _id: int) -> Base: + """ + Get the entity that matches the id + """ + return cls.query.get(_id) + + # This must be overridden by derived classes + def json(self) -> json: + """ + Get model data in JSON format + """ + return { + 'id': self.id, + 'created_at': self.created_at, + 'updated_at': self.updated_at + } \ No newline at end of file diff --git a/services/web/project/models/group.py b/services/web/project/models/group.py new file mode 100644 index 0000000..19974cc --- /dev/null +++ b/services/web/project/models/group.py @@ -0,0 +1,26 @@ +from __future__ import annotations +from datetime import datetime +from sqlalchemy.ext.associationproxy import association_proxy + +from .base import Base +from .. import db + +class Group(Base): + """ + Group user can be a part of + """ + __tablename__ = "group" + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + name = db.Column(db.String(128)) + associated_users = db.relationship("UserGroupAssociation", back_populates="group") + users = association_proxy('associated_users', 'user') + created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow) + + def __init__(self, + name: str, + created_at: datetime = datetime.now(), + updated_at: datetime = datetime.now(), + **kwargs): + super().__init__(created_at, updated_at) + self.name = name \ No newline at end of file diff --git a/services/web/project/models/user.py b/services/web/project/models/user.py new file mode 100644 index 0000000..96ed5a5 --- /dev/null +++ b/services/web/project/models/user.py @@ -0,0 +1,190 @@ +from __future__ import annotations +import jwt +from enum import Enum, IntFlag +from datetime import datetime, timedelta +from flask import current_app +from sqlalchemy.ext.associationproxy import association_proxy +import json + +from .base import Base +from .. import db, bcrypt +from ..api.common.utils.exceptions import UnauthorizedException, BadRequestException + + +class UserRole(IntFlag): + """" + User role + """ + USER = 1 + ADMIN = 2 + + +class SocialAuth(Enum): + """ + Social authentication providers + """ + FACEBOOK = 'Facebook' + GITHUB = 'GitHub' + + +class User(Base): + """ + User model + """ + __tablename__ = "user" + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + email = db.Column(db.String(128), unique=True, nullable=False) + username = db.Column(db.String(128), unique=True, nullable=False) + name = db.Column(db.String(128), nullable=False) + active = db.Column(db.Boolean, default=True, nullable=False) + role = db.Column(db.Integer, default=UserRole.USER.value, nullable=False) + password = db.Column(db.String(255), nullable=True) + token_hash = db.Column(db.String(255), nullable=True) + email_token_hash = db.Column(db.String(255), nullable=True) + email_validation_date = db.Column(db.DateTime, nullable=True) + + # Social + social_id = db.Column(db.String(128), unique=True, nullable=True) + social_type = db.Column(db.String(64), default=None, nullable=True) + social_access_token = db.Column(db.String, nullable=True) + + # Foreign relationships + associated_groups = db.relationship("UserGroupAssociation", back_populates="user") + groups = association_proxy('associated_groups', 'group') + + def __init__(self, + email: str, + username: str, + password: str = None, + name: str = None, + active: bool = True, + email_validation_date: datetime = None, + role: UserRole = UserRole.USER, + social_id: str = None, + social_type: SocialAuth = None, + social_access_token: str = None, + created_at: datetime = datetime.now(), + updated_at: datetime = datetime.now(), + **kwargs): + super().__init__(created_at, updated_at) + self.email = email + self.username = username + self.name = name + if password: + self.password = bcrypt.generate_password_hash(password, + current_app.config.get('BCRYPT_LOG_ROUNDS')).decode() + self.active = active + self.role = role.value + self.email_validation_date = email_validation_date + if social_type: + self.social_id = social_id + self.social_type = social_type.value + self.social_access_token = social_access_token + + def json(self) -> json: + """ + Get user data in JSON format + """ + return { + 'id': self.id, + 'email': self.email, + 'username': self.username, + 'name': self.name, + 'active': self.active, + 'created_at': self.created_at, + 'updated_at': self.updated_at, + 'role': self.role, + 'role_name': UserRole(self.role).name, + 'social_type': self.social_type, + 'email_validation_date': self.email_validation_date + } + + def encode_auth_token(self) -> str: + """ + Generates the auth token + """ + payload = { + 'exp': datetime.utcnow() + timedelta( + days=current_app.config['TOKEN_EXPIRATION_DAYS'], + seconds=current_app.config['TOKEN_EXPIRATION_SECONDS']), + 'iat': datetime.utcnow(), + 'sub': self.id + } + return jwt.encode( + payload, + current_app.config.get('SECRET_KEY'), + algorithm='HS256' + ) + + @staticmethod + def decode_auth_token(auth_token: str) -> int: + """ + Decodes the auth token - :param auth_token: - :return: integer|string + """ + try: + payload = jwt.decode(auth_token, current_app.config.get('SECRET_KEY')) + return payload['sub'] + except jwt.ExpiredSignatureError: + raise UnauthorizedException(message='Signature expired. Please log in again.') + except jwt.InvalidTokenError: + raise UnauthorizedException(message='Invalid token. Please log in again.') + + def encode_password_token(self) -> bytes: + """ + Generates the auth token + """ + payload = { + 'exp': datetime.utcnow() + timedelta( + days=current_app.config['TOKEN_PASSWORD_EXPIRATION_DAYS'], + seconds=current_app.config['TOKEN_PASSWORD_EXPIRATION_SECONDS']), + 'iat': datetime.utcnow(), + 'sub': self.id + } + return jwt.encode( + payload, + current_app.config.get('SECRET_KEY'), + algorithm='HS256' + ) + + @staticmethod + def decode_password_token(pass_token: str) -> int: + """ + Decodes the auth token - :param auth_token: - :return: integer|string + """ + try: + payload = jwt.decode(pass_token, current_app.config.get('SECRET_KEY')) + return payload['sub'] + except jwt.ExpiredSignatureError: + raise BadRequestException(message='Password recovery token expired. Please try again.') + except jwt.InvalidTokenError: + raise BadRequestException(message='Invalid password recovery token. Please try again.') + + def encode_email_token(self) -> bytes: + """ + Generates the email token + """ + payload = { + 'exp': datetime.utcnow() + timedelta( + days=current_app.config['TOKEN_EMAIL_EXPIRATION_DAYS'], + seconds=current_app.config['TOKEN_EMAIL_EXPIRATION_SECONDS']), + 'iat': datetime.utcnow(), + 'sub': self.id + } + return jwt.encode( + payload, + current_app.config.get('SECRET_KEY'), + algorithm='HS256' + ) + + @staticmethod + def decode_email_token(email_token: str) -> int: + """ + Decodes the email token - :param auth_token: - :return: integer|string + """ + try: + payload = jwt.decode(email_token, current_app.config.get('SECRET_KEY')) + return payload['sub'] + except jwt.ExpiredSignatureError: + raise BadRequestException(message='Email recovery token expired. Please try again.') + except jwt.InvalidTokenError: + raise BadRequestException(message='Invalid email verification token. Please try again.') \ No newline at end of file diff --git a/services/web/project/models/user_group_association.py b/services/web/project/models/user_group_association.py new file mode 100644 index 0000000..a368036 --- /dev/null +++ b/services/web/project/models/user_group_association.py @@ -0,0 +1,29 @@ +from datetime import datetime + +from .base import Base +from .. import db +from .user import User +from .group import Group + + +class UserGroupAssociation(Base): + """ + User group association model + """ + __tablename__ = "user_group_associations" + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), primary_key=True) + group_id = db.Column(db.Integer, db.ForeignKey('group.id'), primary_key=True) + user = db.relationship("User", back_populates="associated_groups") + group = db.relationship("Group", back_populates="associated_users") + created_at = db.Column(db.DateTime, nullable=False, default=datetime.now()) + updated_at = db.Column(db.DateTime, nullable=False, default=datetime.now(), onupdate=datetime.now()) + + def __init__(self, + user: User, + group: Group, + created_at: datetime = datetime.now(), + updated_at: datetime = datetime.now(), + **kwargs): + super().__init__(created_at, updated_at) + self.user = user + self.group = group diff --git a/services/web/project/static/hello.txt b/services/web/project/static/hello.txt new file mode 100755 index 0000000..32aad8c --- /dev/null +++ b/services/web/project/static/hello.txt @@ -0,0 +1 @@ +hi! diff --git a/services/web/project/tasks/__init__.py b/services/web/project/tasks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/web/project/tasks/mail_tasks.py b/services/web/project/tasks/mail_tasks.py new file mode 100644 index 0000000..69358e0 --- /dev/null +++ b/services/web/project/tasks/mail_tasks.py @@ -0,0 +1,36 @@ +from flask_mail import Message + +from .. import celery, mail + + +@celery.task +def send_async_registration_email(subject, recipient, text_body, html_body): + """ + Send registration email asynchronously + """ + msg = Message(subject=subject, recipients=[recipient]) + msg.body = text_body + msg.html = html_body + mail.send(msg) + + +@celery.task +def send_async_password_recovery_email(subject, recipient, text_body, html_body): + """ + Send password recovery email asynchronously + """ + msg = Message(subject=subject, recipients=[recipient]) + msg.body = text_body + msg.html = html_body + mail.send(msg) + + +@celery.task +def send_async_email_verification_email(subject, recipient, text_body, html_body): + """ + Send verification email asynchronously + """ + msg = Message(subject=subject, recipients=[recipient]) + msg.body = text_body + msg.html = html_body + mail.send(msg) \ No newline at end of file diff --git a/services/web/project/templates/auth/email_verification_user.html b/services/web/project/templates/auth/email_verification_user.html new file mode 100644 index 0000000..61703fc --- /dev/null +++ b/services/web/project/templates/auth/email_verification_user.html @@ -0,0 +1,7 @@ + +
+

Email Verification for {{ config.APP_NAME }}

+

Hi {{user.name}}! Please click the following link to verify your email.

+ Click here +
+ diff --git a/services/web/project/templates/auth/email_verification_user.txt b/services/web/project/templates/auth/email_verification_user.txt new file mode 100644 index 0000000..eed1484 --- /dev/null +++ b/services/web/project/templates/auth/email_verification_user.txt @@ -0,0 +1 @@ +Email Verification for {{ config.APP_NAME }} \ No newline at end of file diff --git a/services/web/project/templates/auth/password_recovery_user.html b/services/web/project/templates/auth/password_recovery_user.html new file mode 100644 index 0000000..456a265 --- /dev/null +++ b/services/web/project/templates/auth/password_recovery_user.html @@ -0,0 +1,7 @@ + +
+

Password recovery for {{ config.APP_NAME }}

+

Hi {{user.name}}! Please click the following link to create your new password.

+ Click here +
+ diff --git a/services/web/project/templates/auth/password_recovery_user.txt b/services/web/project/templates/auth/password_recovery_user.txt new file mode 100644 index 0000000..eed1484 --- /dev/null +++ b/services/web/project/templates/auth/password_recovery_user.txt @@ -0,0 +1 @@ +Email Verification for {{ config.APP_NAME }} \ No newline at end of file diff --git a/services/web/project/templates/auth/welcome_new_user.html b/services/web/project/templates/auth/welcome_new_user.html new file mode 100644 index 0000000..8bd7639 --- /dev/null +++ b/services/web/project/templates/auth/welcome_new_user.html @@ -0,0 +1,7 @@ + +
+

Welcome to {{ config.APP_NAME }}

+

Hi {{user.name}}! Please click the following link to verify your email.

+ Click here +
+ diff --git a/services/web/project/templates/auth/welcome_new_user.txt b/services/web/project/templates/auth/welcome_new_user.txt new file mode 100644 index 0000000..d8b3f37 --- /dev/null +++ b/services/web/project/templates/auth/welcome_new_user.txt @@ -0,0 +1 @@ +Welcome to our website {{user.name}} diff --git a/services/web/requirements.txt b/services/web/requirements.txt new file mode 100755 index 0000000..c2272b4 --- /dev/null +++ b/services/web/requirements.txt @@ -0,0 +1,27 @@ +Flask==1.1.2 +Flask-SQLAlchemy==2.4.3 +Flask-HTTPAuth==4.1.0 +Flask-Testing==0.8.0 +Flask-Mail==0.9.1 +SQLAlchemy==1.3.17 +gunicorn==20.0.4 +psycopg2-binary==2.8.5 +itsdangerous==1.1.0 +Werkzeug==1.0.1 +flask-cors==3.0.8 +flask-bcrypt==0.7.1 +pyjwt==1.7.1 +flask-accept==0.0.6 +celery==4.4.5 +python-dateutil==2.8.1 +python-editor==1.0.4 +requests==2.23.0 +pyamqp==0.1.0.7 +amqp==2.6.0 +coverage==5.1.0 +oauthlib==3.1.0 +email-validator==1.1.1 +flower==0.9.4 +pydantic==1.5.1 +mimesis==4.0.0 +click==7.1.2 \ No newline at end of file diff --git a/services/web/tests/__init__.py b/services/web/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/web/tests/base.py b/services/web/tests/base.py new file mode 100644 index 0000000..4ef019f --- /dev/null +++ b/services/web/tests/base.py @@ -0,0 +1,20 @@ +from flask_testing import TestCase + +from project import app, db + + +class BaseTestCase(TestCase): + """ + Base Test Case + """ + def create_app(self): + app.config.from_object('project.config.TestingConfig') + return app + + def setUp(self): + db.create_all() + db.session.commit() + + def tearDown(self): + db.session.remove() + db.drop_all() \ No newline at end of file diff --git a/services/web/tests/test_admin_users.py b/services/web/tests/test_admin_users.py new file mode 100644 index 0000000..0245dc3 --- /dev/null +++ b/services/web/tests/test_admin_users.py @@ -0,0 +1,1267 @@ +import json +from datetime import datetime, timedelta +from mimesis import Person +from flask import current_app + +from project.models.user import User +from project.api.common.utils.constants import Constants +from project.models.user import UserRole +from tests.base import BaseTestCase +from tests.utils import add_user, add_user_password + + +class TestUsersBlueprint(BaseTestCase): + """ + Test users api endpoints + Methods include: + CRUD calls for users + """ + # Generate fake data with mimesis + data_generator = Person('en') + version = 'v1' + url = f'/{version}/users/' + """ + Test GET + """ + + def test_users_get(self): + """Ensure get single user behaves correctly.""" + user = add_user() + admin, password = add_user_password(role=UserRole.ADMIN) + with self.client: + resp_login = self.client.post( + f'/{self.version}/auth/login', + data=json.dumps(dict( + email=admin.email, + password=password + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + response = self.client.get(f'{self.url}{user.id}', + headers=[('Accept', 'application/json'), + (Constants.HttpHeaders.AUTHORIZATION, + 'Bearer ' + json.loads(resp_login.data.decode())['auth_token'])]) + data = json.loads(response.data.decode()) + self.assertEqual(response.status_code, 200) + self.assertTrue('created_at' in data) + self.assertEqual(user.email, data['email']) + self.assertEqual(user.username, data['username']) + + def test_users_get_no_id(self): + """Ensure error is thrown if an id is not provided.""" + admin, password = add_user_password(role=UserRole.ADMIN) + with self.client: + resp_login = self.client.post( + f'/{self.version}/auth/login', + data=json.dumps(dict( + email=admin.email, + password=password + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + response = self.client.get(f'{self.url}blah', headers=[('Accept', 'application/json'), ( + Constants.HttpHeaders.AUTHORIZATION, + 'Bearer ' + json.loads(resp_login.data.decode())['auth_token'])]) + data = json.loads(response.data.decode()) + self.assertEqual(response.status_code, 404) + self.assertEqual('Not Found', data['name']) + + def test_users_get_incorrect_id(self): + """Ensure error is thrown if the id does not exist.""" + admin, password = add_user_password(role=UserRole.ADMIN) + with self.client: + resp_login = self.client.post( + f'/{self.version}/auth/login', + data=json.dumps(dict( + email=admin.email, + password=password + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + response = self.client.get(f'{self.url}999', headers=[('Accept', 'application/json'), ( + Constants.HttpHeaders.AUTHORIZATION, + 'Bearer ' + json.loads(resp_login.data.decode())['auth_token'])]) + data = json.loads(response.data.decode()) + self.assertEqual(response.status_code, 404) + self.assertEqual('user does not exist', data['message']) + + """ + Test POST + """ + + def test_users_post(self): + """Ensure a new user can be added to the database.""" + admin, password = add_user_password(role=UserRole.ADMIN) + with self.client: + resp_login = self.client.post( + f'/{self.version}/auth/login', + data=json.dumps(dict( + email=admin.email, + password=password + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + + response = self.client.post( + f'{self.url}', + data=json.dumps(dict( + email=self.data_generator.email(), + username=self.data_generator.username(), + name=self.data_generator.full_name(), + password=self.data_generator.password() + )), + content_type='application/json', + headers=[('Accept', 'application/json'), ( + Constants.HttpHeaders.AUTHORIZATION, + 'Bearer ' + json.loads(resp_login.data.decode())['auth_token'])] + ) + self.assertEqual(response.status_code, 201) + data = json.loads(response.data.decode()) + self.assertEqual('user was added', data['message']) + + def test_users_post_invalid_json(self): + """Ensure error is thrown if the JSON object is empty.""" + admin, password = add_user_password(role=UserRole.ADMIN) + with self.client: + resp_login = self.client.post( + f'/{self.version}/auth/login', + data=json.dumps(dict( + email=admin.email, + password=password + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + response = self.client.post( + f'{self.url}', + data=json.dumps(dict()), + content_type='application/json', + headers=[('Accept', 'application/json'), ( + Constants.HttpHeaders.AUTHORIZATION, + 'Bearer ' + json.loads(resp_login.data.decode())['auth_token'])] + ) + data = json.loads(response.data.decode()) + self.assertEqual(response.status_code, 400) + self.assertEqual('Invalid Payload', data['message']) + + def test_users_post_no_email(self): + """Ensure error is thrown if the JSON does not have an email key.""" + admin, password = add_user_password(role=UserRole.ADMIN) + with self.client: + resp_login = self.client.post( + f'/{self.version}/auth/login', + data=json.dumps(dict( + email=admin.email, + password=password + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + response = self.client.post( + f'{self.url}', + data=json.dumps(dict( + username=self.data_generator.username(), + name=self.data_generator.full_name(), + password=self.data_generator.password() + )), + content_type='application/json', + headers=[('Accept', 'application/json'), ( + Constants.HttpHeaders.AUTHORIZATION, + 'Bearer ' + json.loads(resp_login.data.decode())['auth_token'])] + ) + data = json.loads(response.data.decode()) + self.assertEqual(response.status_code, 400) + self.assertEqual(data['message'], 'Validation Error') + self.assertEqual(data['errors'][0]['field'], 'email') + + def test_users_post_no_username(self): + """Ensure error is thrown if the JSON does not have a username key.""" + admin, password = add_user_password(role=UserRole.ADMIN) + with self.client: + resp_login = self.client.post( + f'/{self.version}/auth/login', + data=json.dumps(dict( + email=admin.email, + password=password + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + response = self.client.post( + f'{self.url}', + data=json.dumps(dict( + email=self.data_generator.email(), + name=self.data_generator.full_name(), + password=self.data_generator.password() + )), + content_type='application/json', + headers=[('Accept', 'application/json'), (Constants.HttpHeaders.AUTHORIZATION, + 'Bearer ' + json.loads(resp_login.data.decode())[ + 'auth_token'])] + ) + data = json.loads(response.data.decode()) + self.assertEqual(response.status_code, 400) + self.assertEqual(data['message'], 'Validation Error') + self.assertEqual(data['errors'][0]['field'], 'username') + + def test_users_post_no_name(self): + """Ensure error is thrown if the JSON does not have a name key.""" + admin, password = add_user_password(role=UserRole.ADMIN) + with self.client: + resp_login = self.client.post( + f'/{self.version}/auth/login', + data=json.dumps(dict( + email=admin.email, + password=password + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + response = self.client.post( + f'{self.url}', + data=json.dumps(dict( + email=self.data_generator.email(), + username=self.data_generator.username(), + password=self.data_generator.password() + )), + content_type='application/json', + headers=[('Accept', 'application/json'), (Constants.HttpHeaders.AUTHORIZATION, + 'Bearer ' + json.loads(resp_login.data.decode())[ + 'auth_token'])] + ) + data = json.loads(response.data.decode()) + self.assertEqual(response.status_code, 400) + self.assertEqual(data['message'], 'Validation Error') + self.assertEqual(data['errors'][0]['field'], 'name') + + def test_users_post_duplicate_email(self): + """Ensure error is thrown if the email already exists.""" + admin, password = add_user_password(role=UserRole.ADMIN) + email = self.data_generator.email() + with self.client: + resp_login = self.client.post( + f'/{self.version}/auth/login', + data=json.dumps(dict( + email=admin.email, + password=password + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + auth_token = json.loads(resp_login.data.decode())['auth_token'] + self.client.post( + f'{self.url}', + data=json.dumps(dict( + email=email, + username=self.data_generator.username(), + name=self.data_generator.full_name(), + password=self.data_generator.password() + )), + content_type='application/json', + headers=[('Accept', 'application/json'), ( + Constants.HttpHeaders.AUTHORIZATION, 'Bearer ' + auth_token)] + ) + response = self.client.post( + f'{self.url}', + data=json.dumps(dict( + email=email, + username=self.data_generator.username(), + name=self.data_generator.full_name(), + password=self.data_generator.password() + )), + content_type='application/json', + headers=[('Accept', 'application/json'), ( + Constants.HttpHeaders.AUTHORIZATION, 'Bearer ' + auth_token)] + ) + data = json.loads(response.data.decode()) + self.assertEqual(response.status_code, 400) + self.assertEqual(data['message'], 'Validation Error') + self.assertEqual(data['errors'][0]['field'], 'email') + self.assertEqual(data['errors'][0]['message'], 'email already exists') + + def test_users_post_duplicate_username(self): + """Ensure error is thrown if the username already exists.""" + admin, password = add_user_password(role=UserRole.ADMIN) + username = self.data_generator.username() + with self.client: + resp_login = self.client.post( + f'/{self.version}/auth/login', + data=json.dumps(dict( + email=admin.email, + password=password + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + auth_token = json.loads(resp_login.data.decode())['auth_token'] + self.client.post( + f'{self.url}', + data=json.dumps(dict( + email=self.data_generator.email(), + username=username, + name=self.data_generator.full_name(), + password=self.data_generator.password() + )), + content_type='application/json', + headers=[('Accept', 'application/json'), (Constants.HttpHeaders.AUTHORIZATION, + 'Bearer ' + auth_token)] + ) + response = self.client.post( + f'{self.url}', + data=json.dumps(dict( + email=self.data_generator.email(), + username=username, + name=self.data_generator.full_name(), + password=self.data_generator.password() + )), + content_type='application/json', + headers=[('Accept', 'application/json'), (Constants.HttpHeaders.AUTHORIZATION, + 'Bearer ' + json.loads(resp_login.data.decode())[ + 'auth_token'])] + ) + data = json.loads(response.data.decode()) + self.assertEqual(response.status_code, 400) + self.assertEqual(data['message'], 'Validation Error') + self.assertEqual(data['errors'][0]['field'], 'username') + self.assertEqual(data['errors'][0]['message'], 'username already exists') + + def test_users_post_duplicate_name(self): + """Ensure no error is thrown if the username and email are unique.""" + admin, password = add_user_password(role=UserRole.ADMIN) + name = self.data_generator.full_name() + user_password = self.data_generator.password() + with self.client: + resp_login = self.client.post( + f'/{self.version}/auth/login', + data=json.dumps(dict( + email=admin.email, + password=password + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + auth_token = json.loads(resp_login.data.decode())['auth_token'] + self.client.post( + f'{self.url}', + data=json.dumps(dict( + email=self.data_generator.email(), + username=self.data_generator.username(), + name=name, + password=user_password + )), + content_type='application/json', + headers=[('Accept', 'application/json'), (Constants.HttpHeaders.AUTHORIZATION, + 'Bearer ' + auth_token)] + ) + response = self.client.post( + f'{self.url}', + data=json.dumps(dict( + email=self.data_generator.email(), + username=self.data_generator.username(), + name=name, + password=user_password + )), + content_type='application/json', + headers=[('Accept', 'application/json'), (Constants.HttpHeaders.AUTHORIZATION, + 'Bearer ' + auth_token)] + ) + data = json.loads(response.data.decode()) + self.assertEqual(response.status_code, 201) + self.assertEqual('user was added', data['message']) + + def test_users_post_valid_role(self): + """Ensure a new user can with given role can be added to the database.""" + admin, password = add_user_password(role=UserRole.ADMIN) + email = self.data_generator.email() + with self.client: + resp_login = self.client.post( + f'/{self.version}/auth/login', + data=json.dumps(dict( + email=admin.email, + password=password + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + response = self.client.post( + f'{self.url}', + data=json.dumps(dict( + email=email, + username=self.data_generator.username(), + name=self.data_generator.full_name(), + password=self.data_generator.password(), + role=UserRole.ADMIN + )), + content_type='application/json', + headers=[('Accept', 'application/json'), ( + Constants.HttpHeaders.AUTHORIZATION, + 'Bearer ' + json.loads(resp_login.data.decode())['auth_token'])] + ) + self.assertEqual(response.status_code, 201) + data = json.loads(response.data.decode()) + self.assertEqual('user was added', data['message']) + + user = User.first(User.email == email) + self.assertTrue(user) + self.assertEqual(user.role, UserRole.ADMIN) + + def test_users_post_invalid_role(self): + """Ensure adding user with invalid role shows invalid role error and doesn't add user.""" + admin, password = add_user_password(role=UserRole.ADMIN) + email = self.data_generator.email() + with self.client: + resp_login = self.client.post( + f'/{self.version}/auth/login', + data=json.dumps(dict( + email=admin.email, + password=password + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + auth_token = json.loads(resp_login.data.decode())['auth_token'] + response = self.client.post( + f'{self.url}', + data=json.dumps(dict( + email=email, + username=self.data_generator.username(), + name=self.data_generator.full_name(), + password=self.data_generator.password(), + role='blah' + )), + content_type='application/json', + headers=[('Accept', 'application/json'), ( + Constants.HttpHeaders.AUTHORIZATION, + 'Bearer ' + auth_token)] + ) + self.assertEqual(response.status_code, 400) + data = json.loads(response.data.decode()) + self.assertEqual(data['message'], 'Validation Error') + self.assertEqual(data['errors'][0]['field'], 'role') + self.assertFalse(User.exists(User.email == email)) + + response = self.client.post( + f'{self.url}', + data=json.dumps(dict( + email=email, + username=self.data_generator.username(), + name=self.data_generator.full_name(), + password=self.data_generator.password(), + role='9999' + )), + content_type='application/json', + headers=[('Accept', 'application/json'), ( + Constants.HttpHeaders.AUTHORIZATION, + 'Bearer ' + auth_token)] + ) + + data = json.loads(response.data.decode()) + self.assertEqual(response.status_code, 400) + self.assertEqual(data['message'], 'Validation Error') + self.assertEqual(data['errors'][0]['field'], 'role') + self.assertFalse(User.exists(User.email == email)) + + def test_users_post_login_after(self): + """Ensure an added new user can login.""" + admin, password = add_user_password(role=UserRole.ADMIN) + with self.client: + resp_login = self.client.post( + f'/{self.version}/auth/login', + data=json.dumps(dict( + email=admin.email, + password=password + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + user_email = self.data_generator.email() + user_password = self.data_generator.password() + response = self.client.post( + f'{self.url}', + data=json.dumps(dict( + email=user_email, + password=user_password, + username=self.data_generator.username(), + name=self.data_generator.full_name() + )), + content_type='application/json', + headers=[('Accept', 'application/json'), ( + Constants.HttpHeaders.AUTHORIZATION, + 'Bearer ' + json.loads(resp_login.data.decode())['auth_token'])] + ) + self.assertEqual(response.status_code, 201) + + response = self.client.post( + f'/{self.version}/auth/login', + data=json.dumps(dict( + email=user_email, + password=user_password + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + data = json.loads(response.data.decode()) + self.assertEqual(data['message'], 'Successfully logged in.') + self.assertTrue(data['auth_token']) + self.assertEqual(response.content_type, 'application/json') + self.assertEqual(response.status_code, 200) + + """ + Test GET [List] + """ + + def test_users_get_all(self): + """Ensure get all users behaves correctly.""" + created = datetime.now() + timedelta(-60) + user1 = add_user(created_at=created) + user2 = add_user() + created = created + timedelta(-60) + admin, password = add_user_password(role=UserRole.ADMIN, created_at=created) + with self.client: + resp_login = self.client.post( + f'/{self.version}/auth/login', + data=json.dumps(dict( + email=admin.email, + password=password + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + response = self.client.get(f'{self.url}', + headers=[('Accept', 'application/json'), + (Constants.HttpHeaders.AUTHORIZATION, + 'Bearer ' + json.loads(resp_login.data.decode())['auth_token'])]) + data = json.loads(response.data.decode()) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(data['users']), 3) + self.assertTrue('per_page' in data) + self.assertTrue('page' in data) + self.assertTrue('number_of_pages' in data) + + self.assertTrue('created_at' in data['users'][0]) + self.assertTrue('created_at' in data['users'][1]) + self.assertTrue('created_at' in data['users'][2]) + self.assertEqual(admin.email, data['users'][2]['email']) + self.assertEqual(user1.email, data['users'][1]['email']) + self.assertEqual(user2.email, data['users'][0]['email']) + + def test_users_get_all_page(self): + """Ensure page param in get all users behaves correctly.""" + admin, password = add_user_password(role=UserRole.ADMIN) + + per_page = current_app.config['POSTS_PER_PAGE'] + # Add n random users + number_of_items = per_page + for i in range(number_of_items): + add_user() + + with self.client: + resp_login = self.client.post( + f'/{self.version}/auth/login', + data=json.dumps(dict( + email=admin.email, + password=password + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + auth_token = json.loads(resp_login.data.decode())['auth_token'] + + """ Test page param""" + params = dict(page=2) + response = self.client.get(f'{self.url}', query_string=params, + headers=[('Accept', 'application/json'), + (Constants.HttpHeaders.AUTHORIZATION, + 'Bearer ' + auth_token)]) + data = json.loads(response.data.decode()) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(data['users']), 1) + self.assertEqual(data['page'], 2) + self.assertEqual(data['users'][0]['email'], admin.email) + + def test_users_get_all_per_page(self): + """Ensure per_page param in get all users behaves correctly.""" + # Add n random users + number_of_items = 49 + for i in range(number_of_items): + add_user() + + posts_per_page = current_app.config['POSTS_PER_PAGE'] + + admin, password = add_user_password(role=UserRole.ADMIN) + + with self.client: + """ Test default per page""" + resp_login = self.client.post( + f'/{self.version}/auth/login', + data=json.dumps(dict( + email=admin.email, + password=password + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + auth_token = json.loads(resp_login.data.decode())['auth_token'] + response = self.client.get(f'{self.url}', + headers=[('Accept', 'application/json'), + (Constants.HttpHeaders.AUTHORIZATION, + 'Bearer ' + auth_token)]) + data = json.loads(response.data.decode()) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(data['users']), posts_per_page) + self.assertTrue('per_page' in data) + self.assertTrue('page' in data) + self.assertTrue('number_of_pages' in data) + + self.assertEqual(posts_per_page, data['per_page']) + # Add 1 for admin user + self.assertEqual((number_of_items + 1) / posts_per_page, data['number_of_pages']) + + """ Test custom per page""" + posts_per_page = 5 + params = dict(per_page=posts_per_page) + response = self.client.get(f'{self.url}', query_string=params, + headers=[('Accept', 'application/json'), + (Constants.HttpHeaders.AUTHORIZATION, + 'Bearer ' + auth_token)]) + data = json.loads(response.data.decode()) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(data['users']), posts_per_page) + self.assertTrue('per_page' in data) + self.assertTrue('page' in data) + self.assertTrue('number_of_pages' in data) + + self.assertEqual(posts_per_page, data['per_page']) + self.assertEqual((number_of_items + 1) / posts_per_page, data['number_of_pages']) + + def test_users_get_all_do_not_exceed_max_page(self): + """Ensure per_page param query exceeding max_per_page returns only max_per_page items.""" + max_per_page = current_app.config['MAX_PER_PAGE'] + # Add n random users + number_of_items = max_per_page + 1 + for i in range(number_of_items): + add_user() + + admin, password = add_user_password(role=UserRole.ADMIN) + with self.client: + resp_login = self.client.post( + f'/{self.version}/auth/login', + data=json.dumps(dict( + email=admin.email, + password=password + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + params = dict(per_page=(max_per_page + 1)) + response = self.client.get(f'{self.url}', query_string=params, + headers=[('Accept', 'application/json'), + (Constants.HttpHeaders.AUTHORIZATION, + 'Bearer ' + json.loads(resp_login.data.decode())[ + 'auth_token'])]) + data = json.loads(response.data.decode()) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(data['users']), max_per_page) + self.assertTrue('per_page' in data) + self.assertEqual(data['per_page'], max_per_page) + + def test_users_get_all_filter(self): + """Ensure filter in get all users behaves correctly.""" + # Add n random users + number_of_items = 49 + for i in range(number_of_items): + add_user() + + admin, password = add_user_password(role=UserRole.ADMIN) + + with self.client: + resp_login = self.client.post( + f'/{self.version}/auth/login', + data=json.dumps(dict( + email=admin.email, + password=password + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + params = dict(filter='(id < 3)') + response = self.client.get(f'{self.url}', query_string=params, + headers=[('Accept', 'application/json'), + (Constants.HttpHeaders.AUTHORIZATION, + 'Bearer ' + json.loads(resp_login.data.decode())['auth_token'])]) + data = json.loads(response.data.decode()) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(data['users']), 2) + self.assertTrue('number_of_pages' in data) + self.assertEqual(1, data['number_of_pages']) + + def test_users_get_all_order_by(self): + """Ensure order_by in get all users behaves correctly.""" + user_list = [] + # Add n random users + number_of_items = 5 + for i in range(number_of_items): + user_list.append(add_user()) + + admin, password = add_user_password(role=UserRole.ADMIN) + user_list.append(admin) + with self.client: + resp_login = self.client.post( + f'/{self.version}/auth/login', + data=json.dumps(dict( + email=admin.email, + password=password + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + auth_token = json.loads(resp_login.data.decode())['auth_token'] + """ Tests ascending order""" + params = dict(order_by='(created_at asc)') + response = self.client.get(f'{self.url}', query_string=params, + headers=[('Accept', 'application/json'), + (Constants.HttpHeaders.AUTHORIZATION, + 'Bearer ' + auth_token)]) + data = json.loads(response.data.decode()) + self.assertEqual(response.status_code, 200) + + for i in range(len(data['users'])): + self.assertEqual(data['users'][i]['email'], user_list[i].email) + + """ Tests descending order""" + params = dict(order_by='(created_at desc)') + response = self.client.get(f'{self.url}', query_string=params, + headers=[('Accept', 'application/json'), + (Constants.HttpHeaders.AUTHORIZATION, + 'Bearer ' + auth_token)]) + data = json.loads(response.data.decode()) + self.assertEqual(response.status_code, 200) + for i in range(len(data['users'])): + self.assertNotEqual(data['users'][i]['email'], user_list[i].email) + + """ + Test PUT + """ + + def test_users_put(self): + """Ensure an existing user can be updated with new data.""" + admin, password = add_user_password(role=UserRole.ADMIN) + user = add_user() + with self.client: + resp_login = self.client.post( + f'/{self.version}/auth/login', + data=json.dumps(dict( + email=admin.email, + password=password + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + email = self.data_generator.email() + username = self.data_generator.username() + name = self.data_generator.full_name() + + response = self.client.put( + f'{self.url}{user.id}', + data=json.dumps(dict( + email=email, + username=username, + name=name, + role=UserRole.ADMIN.value + )), + content_type='application/json', + headers=[('Accept', 'application/json'), ( + Constants.HttpHeaders.AUTHORIZATION, + 'Bearer ' + json.loads(resp_login.data.decode())['auth_token'])] + ) + self.assertEqual(response.status_code, 200) + data = json.loads(response.data.decode()) + self.assertEqual('user was updated', data['message']) + + self.assertEqual(email, user.email) + self.assertEqual(username, user.username) + self.assertEqual(name, user.name) + self.assertEqual(UserRole.ADMIN.value, user.role) + + def test_users_put_no_change(self): + """Ensure update call works with same data without any changes.""" + admin, password = add_user_password(role=UserRole.ADMIN) + email = self.data_generator.email() + username = self.data_generator.username() + name = self.data_generator.full_name() + user = add_user(email=email, username=username, name=name) + + with self.client: + resp_login = self.client.post( + f'/{self.version}/auth/login', + data=json.dumps(dict( + email=admin.email, + password=password + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + + response = self.client.put( + f'{self.url}{user.id}', + data=json.dumps(dict( + email=email, + username=username, + name=name, + role=UserRole.ADMIN.value + )), + content_type='application/json', + headers=[('Accept', 'application/json'), ( + Constants.HttpHeaders.AUTHORIZATION, + 'Bearer ' + json.loads(resp_login.data.decode())['auth_token'])] + ) + self.assertEqual(response.status_code, 200) + data = json.loads(response.data.decode()) + self.assertEqual('user was updated', data['message']) + + self.assertEqual(email, user.email) + self.assertEqual(username, user.username) + self.assertEqual(name, user.name) + self.assertEqual(UserRole.ADMIN.value, user.role) + + def test_users_put_invalid_json(self): + """Ensure error is thrown if the JSON object is empty.""" + user = add_user() + admin, password = add_user_password(role=UserRole.ADMIN) + with self.client: + resp_login = self.client.post( + f'/{self.version}/auth/login', + data=json.dumps(dict( + email=admin.email, + password=password + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + response = self.client.put( + f'{self.url}{user.id}', + data=json.dumps(dict()), + content_type='application/json', + headers=[('Accept', 'application/json'), ( + Constants.HttpHeaders.AUTHORIZATION, + 'Bearer ' + json.loads(resp_login.data.decode())['auth_token'])] + ) + data = json.loads(response.data.decode()) + self.assertEqual(response.status_code, 400) + self.assertEqual('Invalid Payload', data['message']) + + def test_users_put_no_email(self): + """Ensure no error is thrown if the JSON does not have an email key.""" + user = add_user() + admin, password = add_user_password(role=UserRole.ADMIN) + username = self.data_generator.username() + name = self.data_generator.full_name() + with self.client: + resp_login = self.client.post( + f'/{self.version}/auth/login', + data=json.dumps(dict( + email=admin.email, + password=password + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + response = self.client.put( + f'{self.url}{user.id}', + data=json.dumps(dict( + username=username, + name=name, + password=self.data_generator.password() + )), + content_type='application/json', + headers=[('Accept', 'application/json'), ( + Constants.HttpHeaders.AUTHORIZATION, + 'Bearer ' + json.loads(resp_login.data.decode())['auth_token'])] + ) + data = json.loads(response.data.decode()) + self.assertEqual(response.status_code, 200) + self.assertEqual('user was updated', data['message']) + self.assertEqual(user.username, username) + self.assertEqual(user.name, name) + + def test_users_put_no_username(self): + """Ensure no error is thrown if the JSON does not have a username key.""" + user = add_user() + admin, password = add_user_password(role=UserRole.ADMIN) + email = self.data_generator.email() + name = self.data_generator.full_name() + with self.client: + resp_login = self.client.post( + f'/{self.version}/auth/login', + data=json.dumps(dict( + email=admin.email, + password=password + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + response = self.client.put( + f'{self.url}{user.id}', + data=json.dumps(dict( + email=email, + name=name, + password=self.data_generator.password() + )), + content_type='application/json', + headers=[('Accept', 'application/json'), (Constants.HttpHeaders.AUTHORIZATION, + 'Bearer ' + json.loads(resp_login.data.decode())[ + 'auth_token'])] + ) + data = json.loads(response.data.decode()) + self.assertEqual(response.status_code, 200) + self.assertEqual('user was updated', data['message']) + self.assertEqual(user.email, email) + self.assertEqual(user.name, name) + + def test_users_put_no_name(self): + """Ensure no error is thrown if the JSON does not have a name key.""" + user = add_user() + admin, password = add_user_password(role=UserRole.ADMIN) + email = self.data_generator.email() + username = self.data_generator.username() + with self.client: + resp_login = self.client.post( + f'/{self.version}/auth/login', + data=json.dumps(dict( + email=admin.email, + password=password + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + response = self.client.put( + f'{self.url}{user.id}', + data=json.dumps(dict( + email=email, + username=username, + password=self.data_generator.password() + )), + content_type='application/json', + headers=[('Accept', 'application/json'), (Constants.HttpHeaders.AUTHORIZATION, + 'Bearer ' + json.loads(resp_login.data.decode())[ + 'auth_token'])] + ) + data = json.loads(response.data.decode()) + self.assertEqual(response.status_code, 200) + self.assertEqual('user was updated', data['message']) + self.assertEqual(user.username, username) + self.assertEqual(user.email, email) + + def test_users_put_duplicate_email(self): + """Ensure error is thrown if the email already exists.""" + admin, password = add_user_password(role=UserRole.ADMIN) + email = self.data_generator.email() + username = self.data_generator.username() + name = self.data_generator.full_name() + add_user(email=email) + user = add_user() + with self.client: + resp_login = self.client.post( + f'/{self.version}/auth/login', + data=json.dumps(dict( + email=admin.email, + password=password + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + auth_token = json.loads(resp_login.data.decode())['auth_token'] + response = self.client.put( + f'{self.url}{user.id}', + data=json.dumps(dict( + email=email, + username=username, + name=name, + password=self.data_generator.password() + )), + content_type='application/json', + headers=[('Accept', 'application/json'), ( + Constants.HttpHeaders.AUTHORIZATION, 'Bearer ' + auth_token)] + ) + data = json.loads(response.data.decode()) + self.assertEqual(response.status_code, 400) + self.assertEqual(data['message'], 'Validation Error') + self.assertEqual(data['errors'][0]['field'], 'email') + self.assertEqual(data['errors'][0]['message'], 'email already exists') + self.assertNotEqual(user.username, username) + self.assertNotEqual(user.email, email) + self.assertNotEqual(user.name, name) + + def test_users_put_duplicate_username(self): + """Ensure error is thrown if the username already exists.""" + admin, password = add_user_password(role=UserRole.ADMIN) + email = self.data_generator.email() + username = self.data_generator.username() + name = self.data_generator.full_name() + add_user(username=username) + user = add_user() + with self.client: + resp_login = self.client.post( + f'/{self.version}/auth/login', + data=json.dumps(dict( + email=admin.email, + password=password + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + auth_token = json.loads(resp_login.data.decode())['auth_token'] + response = self.client.put( + f'{self.url}{user.id}', + data=json.dumps(dict( + email=email, + username=username, + name=name, + password=self.data_generator.password() + )), + content_type='application/json', + headers=[('Accept', 'application/json'), ( + Constants.HttpHeaders.AUTHORIZATION, 'Bearer ' + auth_token)] + ) + data = json.loads(response.data.decode()) + self.assertEqual(response.status_code, 400) + self.assertEqual(data['message'], 'Validation Error') + self.assertEqual(data['errors'][0]['field'], 'username') + self.assertEqual(data['errors'][0]['message'], 'username already exists') + self.assertNotEqual(user.username, username) + self.assertNotEqual(user.email, email) + self.assertNotEqual(user.name, name) + + def test_users_put_duplicate_name(self): + """Ensure no error is thrown if the username and email are unique.""" + admin, password = add_user_password(role=UserRole.ADMIN) + name = self.data_generator.full_name() + user_password = self.data_generator.password() + add_user(name=name, password=user_password) + user = add_user() + with self.client: + resp_login = self.client.post( + f'/{self.version}/auth/login', + data=json.dumps(dict( + email=admin.email, + password=password + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + auth_token = json.loads(resp_login.data.decode())['auth_token'] + response = self.client.put( + f'{self.url}{user.id}', + data=json.dumps(dict( + email=user.email, + username=user.username, + name=name, + password=user_password + )), + content_type='application/json', + headers=[('Accept', 'application/json'), ( + Constants.HttpHeaders.AUTHORIZATION, 'Bearer ' + auth_token)] + ) + data = json.loads(response.data.decode()) + self.assertEqual(response.status_code, 200) + self.assertEqual('user was updated', data['message']) + + def test_users_put_valid_role(self): + """Ensure role can be updated for user.""" + admin, password = add_user_password(role=UserRole.ADMIN) + user = add_user() + with self.client: + resp_login = self.client.post( + f'/{self.version}/auth/login', + data=json.dumps(dict( + email=admin.email, + password=password + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + response = self.client.put( + f'{self.url}{user.id}', + data=json.dumps(dict( + role=UserRole.ADMIN + )), + content_type='application/json', + headers=[('Accept', 'application/json'), ( + Constants.HttpHeaders.AUTHORIZATION, + 'Bearer ' + json.loads(resp_login.data.decode())['auth_token'])] + ) + self.assertEqual(response.status_code, 200) + data = json.loads(response.data.decode()) + self.assertEqual('user was updated', data['message']) + self.assertEqual(user.role, UserRole.ADMIN) + + def test_users_put_invalid_role(self): + """Ensure updating user with invalid role shows invalid role error and doesn't update user.""" + admin, password = add_user_password(role=UserRole.ADMIN) + user = add_user() + email = self.data_generator.email() + username = self.data_generator.username() + name = self.data_generator.full_name() + + with self.client: + resp_login = self.client.post( + f'/{self.version}/auth/login', + data=json.dumps(dict( + email=admin.email, + password=password + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + auth_token = json.loads(resp_login.data.decode())['auth_token'] + response = self.client.put( + f'{self.url}{user.id}', + data=json.dumps(dict( + username=username, + email=email, + name=name, + role='blah' + )), + content_type='application/json', + headers=[('Accept', 'application/json'), ( + Constants.HttpHeaders.AUTHORIZATION, + 'Bearer ' + auth_token)] + ) + self.assertEqual(response.status_code, 400) + data = json.loads(response.data.decode()) + self.assertEqual(data['message'], 'Validation Error') + self.assertEqual(data['errors'][0]['field'], 'role') + self.assertEqual(user.role, UserRole.USER.value) + + response = self.client.put( + f'{self.url}{user.id}', + data=json.dumps(dict( + username=username, + email=email, + name=name, + role='9999' + )), + content_type='application/json', + headers=[('Accept', 'application/json'), ( + Constants.HttpHeaders.AUTHORIZATION, + 'Bearer ' + auth_token)] + ) + self.assertEqual(response.status_code, 400) + data = json.loads(response.data.decode()) + self.assertEqual(data['message'], 'Validation Error') + self.assertEqual(data['errors'][0]['field'], 'role') + self.assertEqual(user.role, UserRole.USER.value) + self.assertNotEqual(user.username, username) + self.assertNotEqual(user.name, name) + self.assertNotEqual(user.email, email) + + def test_users_put_login_after(self): + """Ensure an updated new user can login.""" + admin, password = add_user_password(role=UserRole.ADMIN) + with self.client: + resp_login = self.client.post( + f'/{self.version}/auth/login', + data=json.dumps(dict( + email=admin.email, + password=password + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + user_email = self.data_generator.email() + user_password = self.data_generator.password() + user = add_user() + response = self.client.put( + f'{self.url}{user.id}', + data=json.dumps(dict( + email=user_email, + password=user_password, + username=self.data_generator.username(), + name=self.data_generator.full_name() + )), + content_type='application/json', + headers=[('Accept', 'application/json'), ( + Constants.HttpHeaders.AUTHORIZATION, + 'Bearer ' + json.loads(resp_login.data.decode())['auth_token'])] + ) + self.assertEqual(response.status_code, 200) + + response = self.client.post( + f'/{self.version}/auth/login', + data=json.dumps(dict( + email=user_email, + password=user_password + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + data = json.loads(response.data.decode()) + self.assertEqual(data['message'], 'Successfully logged in.') + self.assertTrue(data['auth_token']) + self.assertEqual(response.content_type, 'application/json') + self.assertEqual(response.status_code, 200) + + """ + Test DELETE + """ + + def test_users_delete(self): + """Ensure delete single user behaves correctly.""" + admin, password = add_user_password(role=UserRole.ADMIN) + user = add_user() + with self.client: + resp_login = self.client.post( + f'/{self.version}/auth/login', + data=json.dumps(dict( + email=admin.email, + password=password + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + response = self.client.delete( + f'{self.url}{user.id}', + headers=[('Accept', 'application/json'), + (Constants.HttpHeaders.AUTHORIZATION, + 'Bearer ' + json.loads(resp_login.data.decode())['auth_token'])]) + data = json.loads(response.data.decode()) + self.assertEqual(response.status_code, 200) + self.assertEqual('user was deleted', data['message']) + + self.assertFalse(User.exists(User.id == user.id)) + + response = self.client.get( + f'{self.url}{user.id}', + headers=[('Accept', 'application/json'), ( + Constants.HttpHeaders.AUTHORIZATION, + 'Bearer ' + json.loads(resp_login.data.decode())['auth_token'])]) + + data = json.loads(response.data.decode()) + self.assertEqual(response.status_code, 404) + self.assertEqual('user does not exist', data['message']) + + def test_users_delete_incorrect_id(self): + """Ensure error is thrown if the id to be deleted does not exist.""" + admin, password = add_user_password(role=UserRole.ADMIN) + with self.client: + resp_login = self.client.post( + f'/{self.version}/auth/login', + data=json.dumps(dict( + email=admin.email, + password=password + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + response = self.client.delete( + f'{self.url}999', + headers=[('Accept', 'application/json'), + (Constants.HttpHeaders.AUTHORIZATION, + 'Bearer ' + json.loads(resp_login.data.decode())['auth_token'])]) + data = json.loads(response.data.decode()) + self.assertEqual(response.status_code, 404) + self.assertEqual('user does not exist', data['message']) diff --git a/services/web/tests/test_auth_core.py b/services/web/tests/test_auth_core.py new file mode 100644 index 0000000..51c911d --- /dev/null +++ b/services/web/tests/test_auth_core.py @@ -0,0 +1,436 @@ +import json +import time +from mimesis import Person + +from project.api.common.utils.constants import Constants +from tests.base import BaseTestCase +from tests.utils import add_user, add_user_password + + +class TestAuthCore(BaseTestCase): + """ + Test authentication api api/v1/auth.py core endpoints + Methods include: + /auth/register + /auth/login + /auth/logout + /auth/status + /user --> to get user info + """ + # Generate fake data with mimesis + data_generator = Person('en') + + """ + Test /auth/register + """ + + def test_auth_register(self): + """Ensure normal registration works""" + with self.client: + response = self.client.post( + '/v1/auth/register', + data=json.dumps(dict( + username=self.data_generator.username(), + email=self.data_generator.email(), + name=self.data_generator.full_name(), + password=self.data_generator.password() + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + data = json.loads(response.data.decode()) + self.assertEqual(data['message'], 'Successfully registered.') + self.assertTrue(data['auth_token']) + self.assertEqual(response.content_type, 'application/json') + self.assertEqual(response.status_code, 201) + + def test_auth_register_duplicate_email(self): + """Ensure duplicate email registration is not allowed""" + user = add_user() + with self.client: + response = self.client.post( + '/v1/auth/register', + data=json.dumps(dict( + username=self.data_generator.username(), + email=user.email, + password=self.data_generator.password(), + name=self.data_generator.full_name() + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + data = json.loads(response.data.decode()) + self.assertEqual(response.status_code, 400) + self.assertEqual(data['message'], 'Validation Error') + self.assertEqual(data['errors'][0]['field'], 'email') + self.assertEqual(data['errors'][0]['message'], 'email already exists') + + def test_auth_register_duplicate_username(self): + """Ensure duplicate username registration is not allowed""" + user = add_user() + with self.client: + response = self.client.post( + '/v1/auth/register', + data=json.dumps(dict( + username=user.username, + email=self.data_generator.email(), + password=self.data_generator.password(), + name=self.data_generator.full_name() + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + data = json.loads(response.data.decode()) + self.assertEqual(response.status_code, 400) + self.assertEqual(data['message'], 'Validation Error') + self.assertEqual(data['errors'][0]['field'], 'username') + self.assertEqual(data['errors'][0]['message'], 'username already exists') + + def test_auth_register_duplicate_allowable(self): + """Ensure duplicate password/name is allowed""" + user = add_user() + with self.client: + response = self.client.post( + '/v1/auth/register', + data=json.dumps(dict( + username=self.data_generator.username(), + email=self.data_generator.email(), + password=user.password, + name=user.name + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + data = json.loads(response.data.decode()) + self.assertEqual(data['message'], 'Successfully registered.') + self.assertTrue(data['auth_token']) + self.assertEqual(response.content_type, 'application/json') + self.assertEqual(response.status_code, 201) + + def test_auth_register_invalid_json_no_email(self): + """Ensure registration is not allowed without email""" + with self.client: + response = self.client.post( + '/v1/auth/register', + data=json.dumps(dict( + username=self.data_generator.username(), + password=self.data_generator.password(), + name=self.data_generator.full_name() + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + data = json.loads(response.data.decode()) + self.assertEqual(response.status_code, 400) + self.assertEqual(data['message'], 'Validation Error') + self.assertEqual(data['errors'][0]['field'], 'email') + + def test_auth_register_invalid_json_no_username(self): + """Ensure registration is not allowed without username""" + with self.client: + response = self.client.post( + '/v1/auth/register', + data=json.dumps(dict( + email=self.data_generator.email(), + password=self.data_generator.password(), + name=self.data_generator.full_name() + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + data = json.loads(response.data.decode()) + self.assertEqual(response.status_code, 400) + self.assertEqual(data['message'], 'Validation Error') + self.assertEqual(data['errors'][0]['field'], 'username') + + def test_auth_register_invalid_json_no_name(self): + """Ensure registration is not allowed without name""" + with self.client: + response = self.client.post( + '/v1/auth/register', + data=json.dumps(dict( + email=self.data_generator.email(), + password=self.data_generator.password(), + username=self.data_generator.username() + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + data = json.loads(response.data.decode()) + self.assertEqual(response.status_code, 400) + self.assertEqual(data['message'], 'Validation Error') + self.assertEqual(data['errors'][0]['field'], 'name') + + def test_auth_register_invalid_json_no_password(self): + """Ensure registration is not allowed without password""" + with self.client: + response = self.client.post( + '/v1/auth/register', + data=json.dumps(dict( + email=self.data_generator.email(), + username=self.data_generator.username(), + name=self.data_generator.full_name() + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + data = json.loads(response.data.decode()) + self.assertEqual(response.status_code, 400) + self.assertEqual(data['message'], 'Validation Error') + self.assertEqual(data['errors'][0]['field'], 'password') + + def test_auth_register_empty_json(self): + """Ensure empty json gives valid error""" + with self.client: + response = self.client.post( + '/v1/auth/register', + data=json.dumps(dict()), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + data = json.loads(response.data.decode()) + self.assertEqual(response.status_code, 400) + self.assertEqual('Invalid Payload', data['message']) + + """ + Test /auth/login + """ + + def test_auth_login(self): + """Ensure registered user can login""" + user, password = add_user_password() + with self.client: + response = self.client.post( + '/v1/auth/login', + data=json.dumps(dict( + email=user.email, + password=password + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + data = json.loads(response.data.decode()) + self.assertEqual(data['message'], 'Successfully logged in.') + self.assertTrue(data['auth_token']) + self.assertEqual(response.content_type, 'application/json') + self.assertEqual(response.status_code, 200) + + def test_auth_login_no_password(self): + """Ensure registered user cannot login without password""" + user = add_user() + with self.client: + response = self.client.post( + '/v1/auth/login', + data=json.dumps(dict( + email=user.email + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + data = json.loads(response.data.decode()) + self.assertEqual(data['message'], 'Validation Error') + self.assertEqual(data['errors'][0]['field'], 'password') + self.assertEqual(response.status_code, 400) + + def test_auth_login_no_email(self): + """Ensure registered user cannot login without email""" + user, password = add_user_password() + with self.client: + response = self.client.post( + '/v1/auth/login', + data=json.dumps(dict( + password=password + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + data = json.loads(response.data.decode()) + self.assertEqual(data['message'], 'Validation Error') + self.assertEqual(data['errors'][0]['field'], 'email') + self.assertEqual(response.status_code, 400) + + def test_auth_login_incorrect_password(self): + """Ensure registered user cannot login with incorrect password""" + user = add_user() + with self.client: + response = self.client.post( + '/v1/auth/login', + data=json.dumps(dict( + email=user.email, + password=self.data_generator.password() + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + data = json.loads(response.data.decode()) + self.assertEqual(data['message'], 'Incorrect password.') + self.assertEqual(response.status_code, 400) + + def test_auth_login_not_registered(self): + """Ensure not registered user cannot login""" + with self.client: + response = self.client.post( + '/v1/auth/login', + data=json.dumps(dict( + email=self.data_generator.email(), + password=self.data_generator.password() + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + data = json.loads(response.data.decode()) + self.assertEqual(data['message'], 'User does not exist.') + self.assertEqual(response.content_type, 'application/json') + self.assertEqual(response.status_code, 404) + + """ + Test /auth/logout + """ + + def test_auth_logout(self): + """Ensure auth logout works""" + user, password = add_user_password() + with self.client: + # User login + resp_login = self.client.post( + '/v1/auth/login', + data=json.dumps(dict( + email=user.email, + password=password + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + # Valid token logout + response = self.client.get( + '/v1/auth/logout', + headers=[('Accept', 'application/json'), + (Constants.HttpHeaders.AUTHORIZATION, + 'Bearer ' + json.loads(resp_login.data.decode())['auth_token'])] + ) + data = json.loads(response.data.decode()) + self.assertEqual(data['message'], 'Successfully logged out.') + self.assertEqual(response.status_code, 200) + + def test_auth_logout_expired_token(self): + """Ensure logout doesn't work with expired token""" + user, password = add_user_password() + with self.client: + resp_login = self.client.post( + '/v1/auth/login', + data=json.dumps(dict( + email=user.email, + password=password + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + # Invalid token logout + time.sleep(4) + response = self.client.get( + '/v1/auth/logout', + headers=[('Accept', 'application/json'), + (Constants.HttpHeaders.AUTHORIZATION, + 'Bearer ' + json.loads(resp_login.data.decode())['auth_token'])] + ) + data = json.loads(response.data.decode()) + self.assertEqual(data['message'], 'Signature expired. Please log in again.') + self.assertEqual(response.status_code, 401) + + def test_auth_logout_no_token(self): + """Ensure logout shows invalid token""" + with self.client: + response = self.client.get( + '/v1/auth/logout', + headers=[('Accept', 'application/json'), (Constants.HttpHeaders.AUTHORIZATION, 'Bearer invalid')]) + data = json.loads(response.data.decode()) + self.assertEqual(data['message'], 'Invalid token. Please log in again.') + self.assertEqual(response.status_code, 401) + + """ + Test /auth/status + """ + + def test_auth_status(self): + """Ensure auth status check works""" + user, password = add_user_password() + with self.client: + resp_login = self.client.post( + '/v1/auth/login', + data=json.dumps(dict( + email=user.email, + password=password + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + response = self.client.get( + '/v1/auth/status', + headers=[('Accept', 'application/json'), ( + Constants.HttpHeaders.AUTHORIZATION, + 'Bearer ' + json.loads(resp_login.data.decode())['auth_token'])] + ) + data = json.loads(response.data.decode()) + self.assertEqual(data['email'], user.email) + self.assertEqual(data['username'], user.username) + self.assertEqual(data['name'], user.name) + self.assertTrue(data['active'] is True) + self.assertTrue(data['created_at']) + self.assertEqual(response.status_code, 200) + + def test_auth_status_invalid(self): + """Ensure invalid token doesn't work for status check""" + with self.client: + response = self.client.get( + '/v1/auth/status', + headers=[('Accept', 'application/json'), (Constants.HttpHeaders.AUTHORIZATION, 'Bearer invalid')] + ) + data = json.loads(response.data.decode()) + self.assertEqual(data['message'], 'Invalid token. Please log in again.') + self.assertEqual(response.status_code, 401) + + """ + Test /user + """ + + def test_user_info(self): + """Ensure user info works""" + user, password = add_user_password() + with self.client: + resp_login = self.client.post( + '/v1/auth/login', + data=json.dumps(dict( + email=user.email, + password=password + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + response = self.client.get( + '/v1/user/', + headers=[('Accept', 'application/json'), ( + Constants.HttpHeaders.AUTHORIZATION, + 'Bearer ' + json.loads(resp_login.data.decode())['auth_token'])] + ) + + data = json.loads(response.data.decode()) + self.assertEqual(data['email'], user.email) + self.assertEqual(data['username'], user.username) + self.assertEqual(data['name'], user.name) + self.assertEqual(data['social_type'], user.social_type) + self.assertEqual(data['social_id'], user.social_id) + self.assertTrue(data['active'] is True) + self.assertTrue(data['created_at']) + self.assertEqual(response.status_code, 200) + + def test_user_info_invalid(self): + """Ensure invalid token doesn't work for user info""" + with self.client: + response = self.client.get( + '/v1/user/', + headers=[('Accept', 'application/json'), + (Constants.HttpHeaders.AUTHORIZATION, 'Bearer invalid')] + ) + data = json.loads(response.data.decode()) + self.assertEqual(data['message'], 'Invalid token. Please log in again.') + self.assertEqual(response.status_code, 401) \ No newline at end of file diff --git a/services/web/tests/test_auth_email_verification.py b/services/web/tests/test_auth_email_verification.py new file mode 100644 index 0000000..2f56dbb --- /dev/null +++ b/services/web/tests/test_auth_email_verification.py @@ -0,0 +1,91 @@ +import json +from mimesis import Person + +from project.api.common.utils.constants import Constants +from tests.base import BaseTestCase +from tests.utils import add_user, set_user_email_token_hash + + +class TestEmailVerificationBlueprint(BaseTestCase): + """ + Test email verification api api/v1/email_verification.py + """ + # Generate fake data with mimesis + data_generator = Person('en') + version = 'v1' + url = '/v1/email_verification/' + + """ + Test /email_verification + """ + + def test_email_verification(self): + """Ensure email verification works""" + password = self.data_generator.password() + user = add_user(password=password) + with self.client: + resp_login = self.client.post( + f'/{self.version}/auth/login', + data=json.dumps(dict( + email=user.email, + password=password + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + + with self.client: + response = self.client.get( + f'{self.url}', + content_type='application/json', + headers=[('Accept', 'application/json'), + (Constants.HttpHeaders.AUTHORIZATION, + 'Bearer ' + json.loads(resp_login.data.decode())['auth_token'])] + ) + self.assertEqual(response.status_code, 200) + data = json.loads(response.data.decode()) + self.assertEqual(data['message'], 'sent email with verification token') + + def test_email_verification_token(self): + """Ensure email verification with token works""" + user = add_user() + token = user.encode_email_token().decode() + user = set_user_email_token_hash(user, token) + + with self.client: + response = self.client.get( + f'{self.url}{token}', + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + data = json.loads(response.data.decode()) + self.assertEqual(response.status_code, 200) + self.assertEqual(data['message'], 'email verified') + self.assertIsNotNone(user.email_validation_date) + + def test_email_verification_resend(self): + """Ensure email verification resend works""" + password = self.data_generator.password() + user = add_user(password=password) + with self.client: + resp_login = self.client.post( + f'/{self.version}/auth/login', + data=json.dumps(dict( + email=user.email, + password=password + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + + with self.client: + response = self.client.get( + f'{self.url}resend', + content_type='application/json', + headers=[('Accept', 'application/json'), + (Constants.HttpHeaders.AUTHORIZATION, + 'Bearer ' + json.loads(resp_login.data.decode())['auth_token'])] + ) + self.assertEqual(response.status_code, 200) + data = json.loads(response.data.decode()) + self.assertEqual(data['message'], 'verification email resent') \ No newline at end of file diff --git a/services/web/tests/test_auth_password_change.py b/services/web/tests/test_auth_password_change.py new file mode 100644 index 0000000..44ef6bf --- /dev/null +++ b/services/web/tests/test_auth_password_change.py @@ -0,0 +1,265 @@ +import json +import time +from mimesis import Person + +from project.api.common.utils.constants import Constants +from tests.base import BaseTestCase +from tests.utils import add_user, set_user_token_hash + + +class TestAuthPasswordChangeBlueprint(BaseTestCase): + """ + Test authentication api api/v1/auth.py password change/recovery endpoints + Methods include: + /auth/password_change + /auth/password_reset + /auth/password_recovery + """ + # Generate fake data with mimesis + data_generator = Person('en') + version = 'v1' + """ + Test /auth/password_change + """ + + def test_auth_password_change(self): + """Ensure password change works by changing password and logging again""" + password = self.data_generator.password() + user = add_user(password=password) + new_password = self.data_generator.password() + with self.client: + resp_login = self.client.post( + f'/{self.version}/auth/login', + data=json.dumps(dict( + email=user.email, + password=password + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + + response = self.client.put( + f'/{self.version}/auth/password_change', + data=json.dumps(dict( + current_password=password, + new_password=new_password + )), + content_type='application/json', + headers=[('Accept', 'application/json'), ( + Constants.HttpHeaders.AUTHORIZATION, + 'Bearer ' + json.loads(resp_login.data.decode())['auth_token'])] + ) + self.assertEqual(response.status_code, 200) + data = json.loads(response.data.decode()) + self.assertEqual(data['message'], 'Successfully changed password.') + + resp_login = self.client.post( + f'/{self.version}/auth/login', + data=json.dumps(dict( + email=user.email, + password=new_password + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + data = json.loads(resp_login.data.decode()) + self.assertEqual(data['message'], 'Successfully logged in.') + self.assertTrue(data['auth_token']) + self.assertEqual(response.content_type, 'application/json') + self.assertEqual(response.status_code, 200) + + def test_auth_password_change_incorrect_password(self): + """Ensure password change doesn't work with incorrect password""" + password = self.data_generator.password() + user = add_user(password=password) + with self.client: + resp_login = self.client.post( + f'/{self.version}/auth/login', + data=json.dumps(dict( + email=user.email, + password=password + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + + response = self.client.put( + f'/{self.version}/auth/password_change', + data=json.dumps(dict( + current_password=self.data_generator.password(), + new_password=self.data_generator.password() + )), + content_type='application/json', + headers=[('Accept', 'application/json'), + (Constants.HttpHeaders.AUTHORIZATION, + 'Bearer ' + json.loads(resp_login.data.decode())['auth_token'])] + ) + self.assertEqual(response.status_code, 400) + data = json.loads(response.data.decode()) + self.assertEqual(data['message'], 'Validation Error') + self.assertEqual(data['errors'][0]['field'], 'current_password') + self.assertEqual(data['errors'][0]['message'], 'Invalid current password. Please try again.') + + """ + Test /auth/password_reset + """ + + def test_auth_password_reset(self): + """Ensure password reset works""" + user = add_user() + password = user.password + token = user.encode_password_token().decode() + set_user_token_hash(user, token) + + new_password = self.data_generator.password() + + with self.client: + response = self.client.put( + f'/{self.version}/auth/password_reset', + data=json.dumps(dict( + token=token, + password=new_password + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + data = json.loads(response.data.decode()) + self.assertEqual(data['message'], 'Successfully reset password.') + self.assertEqual(response.status_code, 200) + # check db password have really changed + self.assertNotEqual(password, user.password) + + def test_auth_password_reset_token_expired(self): + """Ensure password reset with expired token does not work""" + user = add_user() + token = user.encode_password_token().decode() + user = set_user_token_hash(user, token) + user_password_before = user.password + time.sleep(3) + + with self.client: + response = self.client.put( + f'/{self.version}/auth/password_reset', + data=json.dumps(dict( + token=token, + password=self.data_generator.password() + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + data = json.loads(response.data.decode()) + self.assertEqual(data['message'], 'Password recovery token expired. Please try again.') + self.assertEqual(response.status_code, 400) + # check db password has not changed + self.assertEqual(user_password_before, user.password) + + def test_auth_password_reset_token_used(self): + """Ensure password reset with already used token does not work""" + user = add_user() + token = user.encode_password_token().decode() + + user = set_user_token_hash(user, token) + + with self.client: + response = self.client.put( + f'/{self.version}/auth/password_reset', + data=json.dumps(dict( + token=token, + password=self.data_generator.password() + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + data = json.loads(response.data.decode()) + self.assertEqual(data['message'], 'Successfully reset password.') + self.assertEqual(response.status_code, 200) + + user_password_before = user.password + + with self.client: + response = self.client.put( + f'/{self.version}/auth/password_reset', + data=json.dumps(dict( + token=token, + password=self.data_generator.password() + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + data = json.loads(response.data.decode()) + self.assertEqual(data['message'], 'Invalid password reset token. Please try again.') + self.assertEqual(response.status_code, 400) + # check db password has not changed + self.assertEqual(user_password_before, user.password) + + """ + Test /auth/password_recovery + """ + + def test_auth_password_recovery(self): + """Ensure password recovery works""" + user = add_user() + + with self.client: + response = self.client.post( + f'/{self.version}/auth/password_recovery', + data=json.dumps(dict( + email=user.email + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + data = json.loads(response.data.decode()) + self.assertEqual(data['message'], 'Password recovery email sent.') + self.assertEqual(response.status_code, 200) + + def test_auth_password_recovery_user_not_registered(self): + """Ensure password recovery doesn't work with unregistered user""" + with self.client: + response = self.client.post( + f'/{self.version}/auth/password_recovery', + data=json.dumps(dict( + email=self.data_generator.email() + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + data = json.loads(response.data.decode()) + self.assertEqual(response.status_code, 404) + self.assertEqual('Email does not exist.', data['message']) + + def test_auth_passwords_token_hash_are_random(self): + """Ensure password recovery token hashes are random""" + user = add_user() + + with self.client: + response = self.client.post( + f'/{self.version}/auth/password_recovery', + data=json.dumps(dict( + email=user.email + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + data = json.loads(response.data.decode()) + self.assertEqual(data['message'], 'Password recovery email sent.') + self.assertEqual(response.status_code, 200) + + user_2 = add_user() + + with self.client: + response = self.client.post( + f'/{self.version}/auth/password_recovery', + data=json.dumps(dict( + email=user_2.email + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + data = json.loads(response.data.decode()) + self.assertEqual(data['message'], 'Password recovery email sent.') + self.assertEqual(response.status_code, 200) + + self.assertTrue(user.token_hash != user_2.token_hash) + self.assertTrue(user.token_hash is not None) + self.assertTrue(user.token_hash != "") \ No newline at end of file diff --git a/services/web/tests/test_auth_social.py b/services/web/tests/test_auth_social.py new file mode 100644 index 0000000..1933785 --- /dev/null +++ b/services/web/tests/test_auth_social.py @@ -0,0 +1,70 @@ +import json +from mimesis import Person, Cryptographic + +from project.api.v1.auth.social import add_user_into_db +from project.models.user import SocialAuth +from tests.base import BaseTestCase + + +class TestAuthSocialBlueprint(BaseTestCase): + """ + Test social authentication api api/v1/auth_social.py endpoints + Methods include: + /auth/social/set_standalone_user + add_user_into_db method from auth_social class + """ + # Generate fake data with mimesis + data_generator = Person('en') + + """ + Test social_auth registration and login + """ + + def test_auth_social_register(self): + """Ensure user can register with social authentication""" + response = add_user_into_db(email=self.data_generator.email(), + name=self.data_generator.full_name(), + username=self.data_generator.username(), + social_id=self.data_generator.identifier(), + social_type=SocialAuth.FACEBOOK, + access_token=Cryptographic.token_urlsafe()) + + data = json.loads(response.data.decode()) + self.assertEqual(data['message'], f'registered with {SocialAuth.FACEBOOK.value} and logged in') + self.assertTrue(data['auth_token']) + self.assertEqual(response.content_type, 'application/json') + self.assertEqual(response.status_code, 201) + + def test_auth_social_login(self): + """Ensure registered social user can login""" + email = self.data_generator.email() + name = self.data_generator.full_name() + username = self.data_generator.username() + social_id = self.data_generator.identifier() + response = add_user_into_db(email=email, + name=name, + username=username, + social_id=social_id, + social_type=SocialAuth.FACEBOOK, + access_token=Cryptographic.token_urlsafe()) + + data = json.loads(response.data.decode()) + self.assertEqual(data['message'], + f'registered with {SocialAuth.FACEBOOK.value} and logged in') + self.assertTrue(data['auth_token']) + self.assertEqual(response.content_type, 'application/json') + self.assertEqual(response.status_code, 201) + + response = add_user_into_db(email=email, + name=name, + username=username, + social_id=social_id, + social_type=SocialAuth.FACEBOOK, + access_token=Cryptographic.token_urlsafe()) + + data = json.loads(response.data.decode()) + self.assertEqual(data['message'], + f'logged in with {SocialAuth.FACEBOOK.value}') + self.assertTrue(data['auth_token']) + self.assertEqual(response.content_type, 'application/json') + self.assertEqual(response.status_code, 200) diff --git a/services/web/tests/test_config.py b/services/web/tests/test_config.py new file mode 100644 index 0000000..e5ef546 --- /dev/null +++ b/services/web/tests/test_config.py @@ -0,0 +1,90 @@ +import unittest +import os +from flask import current_app +from flask_testing import TestCase + + +class TestDevelopmentConfig(TestCase): + """ + Test Development Config + """ + def create_app(self): + current_app.config.from_object('project.config.DevelopmentConfig') + return current_app + + def test_app_is_development(self): + """Ensure development settings work""" + self.assertIsNotNone(current_app) + self.assertEqual(current_app.config['SECRET_KEY'], os.environ.get('SECRET_KEY')) + self.assertTrue(current_app.config['DEBUG']) + self.assertEqual(current_app.config['SQLALCHEMY_DATABASE_URI'], os.environ.get('DATABASE_URL')) + self.assertEqual(current_app.config['BCRYPT_LOG_ROUNDS'], 4) + self.assertEqual(current_app.config['TOKEN_EXPIRATION_DAYS'], 30) + self.assertEqual(current_app.config['TOKEN_EXPIRATION_SECONDS'], 0) + + self.assertNotEqual(current_app.config['POSTS_PER_PAGE'], None) + self.assertNotEqual(current_app.config['MAX_PER_PAGE'], None) + + self.assertNotEqual(current_app.config['GITHUB_CLIENT_ID'], None) + self.assertNotEqual(current_app.config['GITHUB_CLIENT_SECRET'], None) + self.assertNotEqual(current_app.config['FACEBOOK_CLIENT_ID'], None) + self.assertNotEqual(current_app.config['FACEBOOK_CLIENT_SECRET'], None) + self.assertNotEqual(current_app.config['POSTS_PER_PAGE'], None) + self.assertNotEqual(current_app.config['MAX_PER_PAGE'], None) + + +class TestTestingConfig(TestCase): + """ + Test Testing Config + """ + def create_app(self): + current_app.config.from_object('project.config.TestingConfig') + return current_app + + def test_app_is_testing(self): + """Ensure testing settings work""" + self.assertEqual(current_app.config['SECRET_KEY'], os.environ.get('SECRET_KEY')) + self.assertTrue(current_app.config['DEBUG']) + self.assertTrue(current_app.config['TESTING']) + self.assertFalse(current_app.config['PRESERVE_CONTEXT_ON_EXCEPTION']) + self.assertEqual(current_app.config['SQLALCHEMY_DATABASE_URI'], os.environ.get('DATABASE_TEST_URL')) + self.assertEqual(current_app.config['BCRYPT_LOG_ROUNDS'], 4) + self.assertEqual(current_app.config['TOKEN_EXPIRATION_DAYS'], 0) + self.assertEqual(current_app.config['TOKEN_EXPIRATION_SECONDS'], 3) + + self.assertNotEqual(current_app.config['POSTS_PER_PAGE'], None) + self.assertNotEqual(current_app.config['MAX_PER_PAGE'], None) + + self.assertNotEqual(current_app.config['GITHUB_CLIENT_ID'], None) + self.assertNotEqual(current_app.config['GITHUB_CLIENT_SECRET'], None) + self.assertNotEqual(current_app.config['FACEBOOK_CLIENT_ID'], None) + self.assertNotEqual(current_app.config['FACEBOOK_CLIENT_SECRET'], None) + +class TestProductionConfig(TestCase): + """ + Test Production Config + """ + def create_app(self): + current_app.config.from_object('project.config.ProductionConfig') + return current_app + + def test_app_is_production(self): + """Ensure production settings work""" + self.assertEqual(current_app.config['SECRET_KEY'], os.environ.get('SECRET_KEY')) + self.assertFalse(current_app.config['DEBUG']) + self.assertFalse(current_app.config['TESTING']) + self.assertEqual(current_app.config['BCRYPT_LOG_ROUNDS'], 13) + self.assertEqual(current_app.config['TOKEN_EXPIRATION_DAYS'], 30) + self.assertEqual(current_app.config['TOKEN_EXPIRATION_SECONDS'], 0) + + self.assertNotEqual(current_app.config['POSTS_PER_PAGE'], None) + self.assertNotEqual(current_app.config['MAX_PER_PAGE'], None) + + self.assertNotEqual(current_app.config['GITHUB_CLIENT_ID'], None) + self.assertNotEqual(current_app.config['GITHUB_CLIENT_SECRET'], None) + self.assertNotEqual(current_app.config['FACEBOOK_CLIENT_ID'], None) + self.assertNotEqual(current_app.config['FACEBOOK_CLIENT_SECRET'], None) + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/services/web/tests/test_model_group.py b/services/web/tests/test_model_group.py new file mode 100644 index 0000000..9c4f58d --- /dev/null +++ b/services/web/tests/test_model_group.py @@ -0,0 +1,37 @@ +from mimesis import Person + +from tests.base import BaseTestCase +from tests.utils import add_user, add_group, add_user_group_association + + +class TestGroupModel(BaseTestCase): + """ + Test Group model + """ + # Generate fake data with mimesis + data_generator = Person('en') + + def test_model_group_add_group(self): + """Ensure a group can be added""" + group_name = self.data_generator.occupation() + group = add_group(name=group_name) + self.assertTrue(group.id) + self.assertEqual(group.name, group_name) + self.assertTrue(group.created_at) + self.assertTrue(group.updated_at) + self.assertEqual(len(group.associated_users), 0) + self.assertEqual(len(group.users), 0) + + def test_model_group_verify_associated_users(self): + """Ensure an added group has associated users""" + user = add_user() + group_name = self.data_generator.occupation() + group = add_group(name=group_name) + self.assertEqual(len(group.associated_users), 0) + add_user_group_association(user=user, group=group) + self.assertEqual(len(group.associated_users), 1) + self.assertEqual(group.associated_users[0].user.username, user.username) + self.assertEqual(len(group.users), 1) + self.assertEqual(group.users[0].username, user.username) + self.assertEqual(len(user.groups), 1) + self.assertEqual(user.groups[0].name, group_name) \ No newline at end of file diff --git a/services/web/tests/test_model_user.py b/services/web/tests/test_model_user.py new file mode 100644 index 0000000..d94c3a4 --- /dev/null +++ b/services/web/tests/test_model_user.py @@ -0,0 +1,69 @@ +from sqlalchemy.exc import IntegrityError +from mimesis import Person + +from project import db +from project.models.user import User +from tests.base import BaseTestCase +from tests.utils import add_user + + +class TestUserModel(BaseTestCase): + """ + Test User model + """ + # Generate fake data with mimesis + data_generator = Person('en') + + def test_model_user_add_user(self): + """Ensure adding user model works""" + username = self.data_generator.username() + user = add_user(username=username) + self.assertTrue(user.id) + self.assertEqual(user.username, username) + self.assertTrue(user.password) + self.assertTrue(user.active) + self.assertTrue(user.created_at) + + def test_model_user_add_user_duplicate_username(self): + """Ensure adding user with duplicate username does not work""" + user = add_user() + duplicate_user = User( + username=user.username, + email=self.data_generator.email(), + password=self.data_generator.password(), + name=self.data_generator.full_name() + ) + db.session.add(duplicate_user) + self.assertRaises(IntegrityError, db.session.commit) + + def test_model_user__add_user_duplicate_email(self): + """Ensure adding user with duplicate email does not work""" + user = add_user() + duplicate_user = User( + email=user.email, + username=self.data_generator.username(), + password=self.data_generator.password(), + name=self.data_generator.full_name() + ) + db.session.add(duplicate_user) + self.assertRaises(IntegrityError, db.session.commit) + + def test_model_user_passwords_are_random(self): + """Ensure passwords are randomly hashed""" + password = self.data_generator.password() + user_one = add_user(password=password) + user_two = add_user(password=password) + self.assertNotEqual(user_one.password, user_two.password) + + def test_model_user_encode_auth_token(self): + """Ensure encoding auth token works""" + user = add_user() + auth_token = user.encode_auth_token() + self.assertTrue(isinstance(auth_token, bytes)) + + def test_model_user_decode_auth_token(self): + """Ensure decoding auth token works""" + user = add_user() + auth_token = user.encode_auth_token() + self.assertTrue(isinstance(auth_token, bytes)) + self.assertTrue(User.decode_auth_token(auth_token), user.id) \ No newline at end of file diff --git a/services/web/tests/test_user_user.py b/services/web/tests/test_user_user.py new file mode 100644 index 0000000..2f7f4d5 --- /dev/null +++ b/services/web/tests/test_user_user.py @@ -0,0 +1,41 @@ +import json +from project.api.common.utils.constants import Constants +from tests.base import BaseTestCase +from tests.utils import add_user_password + + +class TestUserBlueprint(BaseTestCase): + """ + Test users api endpoints + Methods include: + CRUD calls for users + """ + version = 'v1' + url = f'/{version}/user/' + + """ + Test GET + """ + + def test_user_get(self): + """Ensure get single user behaves correctly.""" + user, password = add_user_password() + with self.client: + resp_login = self.client.post( + f'/{self.version}/auth/login', + data=json.dumps(dict( + email=user.email, + password=password + )), + content_type='application/json', + headers=[('Accept', 'application/json')] + ) + response = self.client.get(f'{self.url}', + headers=[('Accept', 'application/json'), + (Constants.HttpHeaders.AUTHORIZATION, + 'Bearer ' + json.loads(resp_login.data.decode())['auth_token'])]) + data = json.loads(response.data.decode()) + self.assertEqual(response.status_code, 200) + self.assertTrue('created_at' in data) + self.assertEqual(user.email, data['email']) + self.assertEqual(user.username, data['username']) \ No newline at end of file diff --git a/services/web/tests/utils.py b/services/web/tests/utils.py new file mode 100644 index 0000000..87bb97a --- /dev/null +++ b/services/web/tests/utils.py @@ -0,0 +1,139 @@ +from __future__ import annotations +from datetime import datetime +from mimesis import Person, Cryptographic, Text + +from project import db, app +from project.models.user import User, UserRole, SocialAuth +from project.models.group import Group +from project.models.user_group_association import UserGroupAssociation +from project import bcrypt + +data_generator = Person('en') +data_generator_text = Text() + + +def add_user(role: UserRole = UserRole.USER, + email: str = None, + username: str = None, + password: str = None, + created_at: datetime = None, + name: str = None) -> User: + """ + Generates a fake user to add in DB + """ + if email is None: + email = data_generator.email() + if username is None: + username = data_generator.email() + if password is None: + password = data_generator.email() + if created_at is None: + created_at = datetime.now() + if name is None: + name = data_generator.full_name() + + user = User(email=email, + username=username, + password=password, + name=name, + created_at=created_at, + role=role) + db.session.add(user) + db.session.commit() + return user + + +def add_user_password(role: UserRole = UserRole.USER, + email: str = None, + username: str = None, + password: str = None, + created_at: datetime = None, + name: str = None) -> tuple(User, str): + """ + Generates a fake user to add in DB and return User, password tuple + """ + if email is None: + email = data_generator.email() + if username is None: + username = data_generator.email() + if password is None: + password = data_generator.email() + if created_at is None: + created_at = datetime.now() + if name is None: + name = data_generator.full_name() + + user = User(email=email, + username=username, + password=password, + name=name, + created_at=created_at, + role=role) + db.session.add(user) + db.session.commit() + return user, password + + +def add_social_user(role: UserRole = UserRole.USER, + email: str = None, + username: str = None, + password: str = None) -> User: + """ + Generates a fake social user to add in DB + """ + if email is None: + email = data_generator.email() + if username is None: + username = data_generator.email() + if password is None: + password = data_generator.email() + + user = User(email=email, + username=username, + password=password, + name=data_generator.full_name(), + created_at=datetime.now(), + role=role, + social_type=SocialAuth.FACEBOOK.value, + social_id=data_generator.identifier(), + social_access_token=Cryptographic.token_urlsafe()) + db.session.add(user) + db.session.commit() + return user + +def add_group(name: str) -> Group: + """ + Add a new group in database + """ + group = Group(name=name) + db.session.add(group) + db.session.commit() + return group + + +def add_user_group_association(user: User, group: Group) -> UserGroupAssociation: + """ + Add a new user-group association + """ + user_group_association = UserGroupAssociation(user=user, group=group) + db.session.add(user_group_association) + db.session.commit() + return user_group_association + + +def set_user_token_hash(user: User, token: str) -> User: + """ + Set token hash for user + """ + user.token_hash = bcrypt.generate_password_hash(token, app.config.get('BCRYPT_LOG_ROUNDS')).decode() + db.session.commit() + return user + + +def set_user_email_token_hash(user: User, token: str) -> User: + """ + Set email token hash for user + """ + user.email_token_hash = bcrypt.generate_password_hash(token, app.config.get('BCRYPT_LOG_ROUNDS')).decode() + db.session.commit() + return user