diff --git a/.dockerignore b/.dockerignore index db00cf6..e8ffa4a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,4 @@ *.json .gitignore .travis.yml +/google_secrets.json diff --git a/.gitignore b/.gitignore index 8797299..576b473 100644 --- a/.gitignore +++ b/.gitignore @@ -104,4 +104,5 @@ ENV/ .idea #secrets -coder_directory_api/prod_settings.json \ No newline at end of file +coder_directory_api/prod_settings.json +/google_secrets.json diff --git a/.travis.yml b/.travis.yml index b62fc6f..a542c76 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,3 +1,6 @@ +env: + global: + - CC_TEST_REPORTER_ID=5fcffa3ecf8aa57eefeb35fceff9cd300db4af96a4287dc29e1b1a12ad46eb29 # These are the versions of our backend to be supported language: python python: @@ -10,18 +13,29 @@ python: install: - "pip install -U setuptools" - "pip install -r requirements.txt" + - "pip install coveralls" # we use mongodb as our database services: - mongodb -# Seed the mongodb with some test data +before_install: + - openssl aes-256-cbc -K $encrypted_fedd13c2de32_key -iv $encrypted_fedd13c2de32_iv -in google_secrets.json.enc -out google_secrets.json -d + +# Seed the mongodb with some test data, and setup test coverage reporters before_script: - bash mock_data/db_init.sh + - curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter + - chmod +x ./cc-test-reporter + - ./cc-test-reporter before-build # Run the test suite script: - - nosetests + - coverage run -m nose tests + +after_success: + - coveralls + - ./cc-test-reporter after-build --exit-code $TRAVIS_TEST_RESULT # notify if there is a change in build status notification: diff --git a/README.md b/README.md index 0f643f3..35b7036 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,8 @@ [![Build Status][travis]](https://travis-ci.org/seekheart/coder_directory_api) [![License][license]](https://img.shields.io/badge/license-MIT%20License-blue.svg) [![Version][version]](https://img.shields.io/badge/Version-1.0.0-brightgreen.svg) - +[![Maintainability][maintain]](https://codeclimate.com/github/seekheart/coder_directory_api/maintainability) +[![Coverage Status][coverage]](https://coveralls.io/github/seekheart/coder_directory_api?branch=master) The Coder Directory Api is a RESTful api developed to provide management of coders and programming languages. @@ -15,12 +16,14 @@ coders and programming languages. | /register | Registers a user/app to use api | | /login | Login user to obtain token | | /login/token | Send your tokens here to refresh your access before it expires | +| /google | Sign in to google and get access token | | /users | Access users resource for GET/POST | | /users/{id} | Access users resource for GET/PATCH/DELETE for 1 user | | /languages | Access language resource for GET/POST | | /languages/{id} | Access language resource for GET/PATCH/DELETE of 1 language | -With the exception of the `register` and `login` endpoints all resources + +With the exception of the `register`, `google`, and `login` endpoints all resources require a jwt to be sent in the `Authorization` header with `Bearer` scheme. ## Development @@ -77,6 +80,7 @@ The following variables need to be set. * HOST - host address to run app on, defaults to localhost or 0.0.0.0 * PORT - port number to run on, defaults to 3000 * SECRET - path to your secret credentials json file. +* GOOGLE - path to your google credentials json file. In addition for `APP_ENV` this variable will determine whether the app outputs debug messages if not in `PROD` and whether or not `MULTITHREADING` for @@ -89,6 +93,10 @@ signatures for JWT. Included in the project is an example setup file: `dev_settings.json` +### GOOGLE credentials +In order to use google oauth you will need to register a service account with +[google]. + ## Author * **Mike Tung** - *Main Developer* - [Github] @@ -96,5 +104,7 @@ Included in the project is an example setup file: `dev_settings.json` [Github]: https://github.com/seekheart [travis]: https://travis-ci.org/seekheart/coder_directory_api.svg?branch=master [license]: https://img.shields.io/badge/license-MIT%20License-blue.svg -[version]: https://img.shields.io/badge/Version-1.0.0-brightgreen.svg - +[version]: https://img.shields.io/badge/Version-1.1.0-brightgreen.svg +[google]: https://console.developers.google.com +[maintain]: https://api.codeclimate.com/v1/badges/47c92b40567f27394cec/maintainability +[coverage]: https://coveralls.io/repos/github/seekheart/coder_directory_api/badge.svg?branch=master \ No newline at end of file diff --git a/app.py b/app.py index 1a3ef65..8a61f3e 100644 --- a/app.py +++ b/app.py @@ -20,6 +20,7 @@ def create_app() -> Flask: """ api = Flask('__name__') + api.secret_key = SECRET_KEY CORS(api) api.wsgi_app = ProxyFix(api.wsgi_app) api.url_map.strict_slashes = False @@ -49,7 +50,9 @@ def register_resources(api, bp, route=None): if __name__ == '__main__': app = create_app() - app.run(host=HOST, + app.run( + host=HOST, port=PORT, debug=DEBUG, - threaded=MULTITHREADING) + threaded=MULTITHREADING, + ) diff --git a/coder_directory_api/api.py b/coder_directory_api/api.py index edf05b0..fa129c8 100644 --- a/coder_directory_api/api.py +++ b/coder_directory_api/api.py @@ -20,6 +20,7 @@ def create_app() -> Flask: """ api = Flask('__name__') + api.secret_key = SECRET_KEY CORS(api) api.wsgi_app = ProxyFix(api.wsgi_app) api.url_map.strict_slashes = False diff --git a/coder_directory_api/auth/__init__.py b/coder_directory_api/auth/__init__.py index 6865af5..648d5f6 100644 --- a/coder_directory_api/auth/__init__.py +++ b/coder_directory_api/auth/__init__.py @@ -5,5 +5,5 @@ MIT License, see LICENSE for details """ -__all__ = ['check_token', 'token_required', 'make_token'] +__all__ = ['check_token', 'token_required', 'make_token', 'refresh_token'] from .jwt_authorization import * diff --git a/coder_directory_api/auth/jwt_authorization.py b/coder_directory_api/auth/jwt_authorization.py index 4a39f0b..d387989 100644 --- a/coder_directory_api/auth/jwt_authorization.py +++ b/coder_directory_api/auth/jwt_authorization.py @@ -16,6 +16,7 @@ # set some global helpers auth_engine = engines.AuthEngine() secret = settings.SECRET_KEY +expire_time = datetime.timedelta(minutes=5) def refresh_token(token) -> dict or None: @@ -28,33 +29,42 @@ def refresh_token(token) -> dict or None: Returns: refreshed jwt token payload. """ + user = token['user'] + user_refresh_token = token['refresh_token'] try: - user = token['user'] - token['access_token'] = jwt.decode(token['access_token'], secret) - token['refresh_token'] = jwt.decode(token['refresh_token'], secret) - except (jwt.ExpiredSignatureError, jwt.DecodeError, jwt.InvalidTokenError): + jwt.decode(token['refresh_token'], secret) + except (jwt.DecodeError, jwt.InvalidTokenError) as e: + return None + + if user: + ref_token = auth_engine.find_one(user=user) + else: return None - ref_token = auth_engine.find_one(user=user) ref_token = ref_token['refresh_token'] - if ref_token == token['refresh_token']: - token['access_token']['exp'] = datetime.datetime.utcnow() + \ - datetime.timedelta(minutes=5) - result = auth_engine.edit_one(user=user, doc=token) - else: + try: + ref_token = ref_token.decode('utf-8') + except AttributeError: return None - token['access_token'] = jwt.encode( - token['access_token'], secret - ).decode('utf-8') - token['refresh_token'] = jwt.encode( - token['refresh_token'], secret - ).decode('utf-8') + if ref_token == user_refresh_token: + new_access_token = make_access_token(user) + result = auth_engine.edit_one( + user=user, + doc={'access_token': new_access_token} + ) + else: + return None if result: - return token + user_doc = { + 'user': user, + 'access_token': new_access_token, + 'refresh_token': token['refresh_token'] + } + return make_payload(user_doc=user_doc) else: return None @@ -78,7 +88,7 @@ def check_token(token) -> bool: jwt.DecodeError, jwt.InvalidTokenError, KeyError - ): + ) as e: result = False else: user = auth_engine.find_one(decoded_token['user']) @@ -136,21 +146,8 @@ def make_token(user: str) -> dict: for unauthenticated clients. """ - renew_token = { - 'iss': 'coder directory', - 'sub': user, - 'created': datetime.datetime.utcnow().strftime('%m/%d/%Y %H:%M:%S'), - 'jti': str(uuid.uuid4()), - 'iat': make_timestamp(), - } - - access_token = { - 'iss': 'coder directory', - 'user': user, - 'jti': str(uuid.uuid4()), - 'iat': make_timestamp(), - 'exp': datetime.datetime.utcnow() + datetime.timedelta(minutes=5) - } + renew_token = make_refresh_token(user) + access_token = make_access_token(user) tokens = { 'access_token': access_token, @@ -158,14 +155,12 @@ def make_token(user: str) -> dict: } auth_engine.edit_one(user=user, doc=tokens) - - payload = { + user_doc = { 'user': user, - 'created': datetime.datetime.utcnow().strftime('%m/%d/%Y %H:%M:%S'), - 'access_token': jwt.encode(access_token, secret).decode('utf-8'), - 'refresh_token': jwt.encode(renew_token, secret).decode('utf-8') + 'access_token': tokens['access_token'], + 'refresh_token': tokens['refresh_token'] } - + payload = make_payload(user_doc=user_doc) return payload @@ -178,3 +173,75 @@ def make_timestamp() -> int: """ date = int(datetime.datetime.utcnow().strftime('%s')) * 1000 return date + + +def make_payload(user_doc: dict) -> dict: + """ + Helper function to make payload for jwt tokens. + Args: + user_doc: dictionary containing user, access_token, refresh_token + + Returns: + api payload for jwt token. + """ + + try: + access_token = user_doc['access_token'].decode('utf-8') + except AttributeError: + access_token = user_doc['access_token'] + + try: + renew_token = user_doc['refresh_token'].decode('utf-8') + except AttributeError: + renew_token = user_doc['refresh_token'] + return { + 'user': user_doc['user'], + 'created': datetime.datetime.now().strftime('%m/%d/%Y %H:%M:%S'), + 'expires_in': expire_time.seconds, + 'access_token': access_token, + 'refresh_token': renew_token + } + + +def make_access_token(user_name: str) -> dict: + """ + Helper function to make the access token. + + Args: + user_name: username to make token for. + + Returns: + encrypted jwt access token + """ + return jwt.encode( + { + 'iss': 'coder directory', + 'user': user_name, + 'jti': str(uuid.uuid4()), + 'iat': make_timestamp(), + 'exp': datetime.datetime.utcnow() + expire_time + }, + secret + ) + + +def make_refresh_token(user_name: str) -> dict: + """ + Helper function to make refresh token. + + Args: + user_name: username to make token for. + + Returns: + encrypted jwt refresh token. + """ + + return jwt.encode( + { + 'iss': 'coder directory', + 'sub': user_name, + 'created': datetime.datetime.utcnow().strftime('%m/%d/%Y %H:%M:%S'), + 'jti': str(uuid.uuid4()), + 'iat': make_timestamp(), + }, + secret) diff --git a/coder_directory_api/resources/__init__.py b/coder_directory_api/resources/__init__.py index 4f1e804..da34c74 100644 --- a/coder_directory_api/resources/__init__.py +++ b/coder_directory_api/resources/__init__.py @@ -13,6 +13,7 @@ from .home import api as home_api from .login import api as login_api from .register import api as register_api +from .google import api as google_api # Create a list of resource objects to register in api api_resources = [ @@ -20,5 +21,6 @@ {'bp': users_api, 'route': 'users'}, {'bp': home_api, 'route': None}, {'bp': login_api, 'route': 'login'}, - {'bp': register_api, 'route': 'register'} + {'bp': register_api, 'route': 'register'}, + {'bp': google_api, 'route': 'google'} ] diff --git a/coder_directory_api/resources/google.py b/coder_directory_api/resources/google.py new file mode 100644 index 0000000..c3b2e1e --- /dev/null +++ b/coder_directory_api/resources/google.py @@ -0,0 +1,111 @@ +""" +Google resource for Coder Directory + +Copyright (c) 2017 by Mike Tung. +MIT License, see LICENSE for details. +""" + +from flask import Blueprint, jsonify, redirect, request +from coder_directory_api.settings import GOOGLE_SECRETS, PORT, google_secrets +from coder_directory_api.engines.auth_engine import AuthEngine +import coder_directory_api.auth as auth +from coder_directory_api.auth import refresh_token + +import google_auth_oauthlib.flow +import googleapiclient.discovery +from google.oauth2 import id_token +from google.auth.transport import requests + +api = Blueprint('google', __name__) +auth_engine = AuthEngine() + +# setup a flow object to manage OAuth exchange. +flow = google_auth_oauthlib.flow.Flow.from_client_secrets_file( + GOOGLE_SECRETS, + scopes=[ + 'https://www.googleapis.com/auth/plus.login', + 'https://www.googleapis.com/auth/plus.me', + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/userinfo.profile', + ]) +flow.redirect_uri = 'http://localhost:{}/api/google/callback'.format(PORT) +authorization_url, state = flow.authorization_url( + access_type='offline', + include_granted_scopes='true' +) + +# setup white list for valid issuers +white_list = ['accounts.google.com', 'https://accounts.google.com'] + + +@api.route('/', methods=['GET']) +def google_login() -> redirect or tuple: + """ + Google OAuth login resource for initiating OAuth Protocol. + Returns: + redirect to google login page if no auth token provided, otherwise, + api will return api access and refresh tokens if google token is valid + along with 200 status code, or 400 status code with error message if + google token. + """ + if request.method == 'GET': + try: + auth_token = request.headers['Authorization'].split(' ')[1] + is_valid, data = _validate_google_token(auth_token) + except KeyError: + return redirect(authorization_url) + + if is_valid: + user = auth_engine.find_one(data) + if not user: + return jsonify({'message': 'User not registered!'}), 404 + user_tokens = { + 'user': user['user'], + 'access_token': user['access_token'], + 'refresh_token': user['refresh_token'] + } + new_tokens = refresh_token(user_tokens) + return jsonify(new_tokens) + else: + return jsonify({'message': data}), 400 + + +@api.route('/callback') +def callback_url() -> None: + """ + Callback uri for handling the second piece of google OAuth after user has + consented. + Returns: + api access and refresh tokens + """ + authorization_response = request.url + flow.fetch_token(authorization_response=authorization_response) + credentials = flow.credentials + d = googleapiclient.discovery.build('oauth2', 'v2', credentials=credentials) + data = d.userinfo().v2().me().get().execute() + auth_doc = {'user': data['email'], 'googleId': data['id']} + auth_engine.add_one(auth_doc) + payload = auth.make_token(auth_doc['user']) + return jsonify(payload) + + +def _validate_google_token(token) -> tuple: + """ + Helper function to validate a google oauth token + + Args: + token: google token + + Returns: + indicator as to whether google token is valid or not and + message/username. + """ + + try: + id_info = id_token.verify_oauth2_token(token, requests.Request()) + if id_info['iss'] not in white_list: + raise ValueError('Wrong Issuer!') + user_name = id_info['email'] + except ValueError: + return False, 'Invalid Google token!' + return True, user_name diff --git a/coder_directory_api/resources/home.py b/coder_directory_api/resources/home.py index bc7706f..b14e4c3 100644 --- a/coder_directory_api/resources/home.py +++ b/coder_directory_api/resources/home.py @@ -23,7 +23,8 @@ def home() -> tuple: 'languages': 'Programming languages resource.', 'users': 'Users resource.', 'login': 'Login to api for access.', - 'register': 'Registration route for access to api.' + 'register': 'Registration route for access to api.', + 'google': 'OAuth2 Google sign in for access' } ] diff --git a/coder_directory_api/resources/login.py b/coder_directory_api/resources/login.py index 9b74079..2a3045a 100644 --- a/coder_directory_api/resources/login.py +++ b/coder_directory_api/resources/login.py @@ -40,8 +40,8 @@ def login() -> tuple: @api.route('/token', methods=['GET', 'POST']) def refresh() -> tuple: """ - Refresh token resource allows users to renew their access token before - expiration. + Refresh token resource allows users to renew their access token with their + refresh token. Returns: Tuple with json containing refreshed token or message with http status @@ -55,7 +55,7 @@ def refresh() -> tuple: data = request.json payload = auth.refresh_token(data) if payload is None: - message = {'message': 'Access token has expired please re-login'} + message = {'message': 'Bad token'} return jsonify(message), 400 return jsonify(payload), 200 diff --git a/coder_directory_api/settings.py b/coder_directory_api/settings.py index d26f395..71c2a34 100644 --- a/coder_directory_api/settings.py +++ b/coder_directory_api/settings.py @@ -15,6 +15,7 @@ ENV = os.environ.get('API_ENV', 'DEV') BASE_URL = os.environ.get('API_BASE_URL', '/dev') + with open(SECRET, 'r') as s: creds = json.load(s) @@ -26,9 +27,15 @@ SECRET_KEY = creds['secretKey'] +GOOGLE_SECRETS = os.environ.get('GOOGLE', None) +with open(GOOGLE_SECRETS, 'r') as g: + google_secrets = json.load(g) + + if ENV == 'DEV': DEBUG = True MULTITHREADING = False + os.environ['OAUTHLIB_INSECURE_TRANSPORT'] = '1' elif ENV == 'PROD': DEBUG = False diff --git a/docker-compose.yml b/docker-compose.yml index f76be61..b59f032 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,8 +8,10 @@ services: - HOST=0.0.0.0 - API_ENV=PROD - API_SECRET=/app/coder_directory_api/prod_settings.json + - GOOGLE=/app/google_secrets.json volumes: - ./coder_directory_api/prod_settings.json:/app/coder_directory_api/prod_settings.json + - ./google_secrets.json:/app/google_secrets.json links: - mongodb mongodb: diff --git a/google_secrets.json.enc b/google_secrets.json.enc new file mode 100644 index 0000000..cf2c877 Binary files /dev/null and b/google_secrets.json.enc differ diff --git a/mock_data/auth.json b/mock_data/auth.json index 05be759..dfd0c52 100644 --- a/mock_data/auth.json +++ b/mock_data/auth.json @@ -1,20 +1,10 @@ [ { + "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJjb2RlciBkaXJlY3RvcnkiLCJ1c2VyIjoidGVzdDEiLCJqdGkiOiIwODc3OTU3Ni02YmRmLTQxMjAtOTQ5Yy02NmQ5NTc4MjM4ZTAiLCJpYXQiOjE1MjA0MDc5MDkwMDAsImV4cCI6MTUyMDM5MDIwOX0.GYUrnHvZUBiGHnSdolj-cfXN8bSm8wLAY23tkJ9LFNk", + "created": "03/06/2018 21:31:49", + "expires_in": 300, + "refresh_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJjb2RlciBkaXJlY3RvcnkiLCJzdWIiOiJ0ZXN0MSIsImNyZWF0ZWQiOiIwMy8wNy8yMDE4IDAyOjMxOjQ5IiwianRpIjoiZWM4OTI4MGYtODExYS00ZjQzLWJlYzItZDI1MGRhZDBmOTRjIiwiaWF0IjoxNTIwNDA3OTA5MDAwfQ.OYOv40XCnLhM8dD3lTlnjkXWbAP_-3mlx4g1J0vkMH0", "user": "test1", - "password": "test", - "access_token": { - "iss": "coder directory", - "user": "test", - "jti": "46cb6d55-7885-4ca7-abaf-2d3694c59af4", - "iat": 1512543251000, - "exp": "2017-12-06T01:59:11.319Z" - }, - "refresh_token": { - "iss": "coder directory", - "sub": "seekheart", - "created": "12/06/2017 01:54:11", - "jti": "928f8e06-453c-436d-8b11-6fe21a2614d2", - "iat": "1512543251000" - } + "password": "test" } ] \ No newline at end of file diff --git a/mock_data/db_init.sh b/mock_data/db_init.sh index 17d8817..e1afb79 100755 --- a/mock_data/db_init.sh +++ b/mock_data/db_init.sh @@ -2,7 +2,7 @@ set -e echo Dropping database... -mongo --eval "db.dropDatabase()" +mongo coder --eval "db.dropDatabase()" echo Database dropped! echo Seeding user data... diff --git a/requirements.txt b/requirements.txt index 7f393b1..dc4a920 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,18 @@ aniso8601==1.3.0 -certifi==2017.11.5 +asn1crypto==0.24.0 +cachetools==2.0.1 +certifi==2018.1.18 +cffi==1.11.5 chardet==3.0.4 click==6.7 +cryptography==2.1.4 Flask==0.12.2 Flask-Cors==3.0.3 +google-api-python-client==1.6.5 +google-auth==1.4.1 +google-auth-httplib2==0.0.3 +google-auth-oauthlib==0.2.0 +httplib2==0.10.3 idna==2.6 itsdangerous==0.24 Jinja2==2.10 @@ -12,7 +21,11 @@ lazy==1.3 MarkupSafe==1.0 mistune==0.8.1 nose==1.3.7 +oauth2client==4.1.2 oauthlib==2.0.6 +pyasn1==0.4.2 +pyasn1-modules==0.2.1 +pycparser==2.18 PyJWT==1.5.3 pymongo==3.5.1 python-dateutil==2.6.1 @@ -20,7 +33,9 @@ pytz==2017.3 PyYAML==3.12 requests==2.18.4 requests-oauthlib==0.8.0 +rsa==3.4.2 six==1.11.0 +uritemplate==3.0.0 urllib3==1.22 URLObject==2.4.3 Werkzeug==0.12.2 diff --git a/tests/api/test_api_google.py b/tests/api/test_api_google.py new file mode 100644 index 0000000..a038370 --- /dev/null +++ b/tests/api/test_api_google.py @@ -0,0 +1,39 @@ +""" +Tests for Google Resource + +Copyright (c) 2017 by Mike Tung. +MIT License, see LICENSE for details. +""" + +from .common_test_setup import CommonApiTest +import json +from coder_directory_api.engines import AuthEngine + + +class GoogleResourceTest(CommonApiTest): + def setUp(self): + """Setup Google Tests""" + super(GoogleResourceTest, self).setUp() + self.endpoint = '{}/google'.format(self.base_url) + + def tearDown(self): + """Teardown method for Google Tests""" + super(GoogleResourceTest, self).tearDown() + + def test_redirect(self): + """Test google oauth sign in redirect""" + result = self.app.get(self.endpoint) + self.assertEqual(result.status_code, + 302, + msg='Expected 301 redirect to Google') + + def test_token(self): + """Test google oauth with bad OAuth token""" + result = self.app.get(self.endpoint, + headers={'Authorization': 'Bearer asdf'}) + + self.assertEquals( + 400, + result.status_code, + msg='Expected 400 status code' + ) diff --git a/tests/api/test_api_login.py b/tests/api/test_api_login.py index 11347fc..994a251 100644 --- a/tests/api/test_api_login.py +++ b/tests/api/test_api_login.py @@ -7,11 +7,8 @@ from .common_test_setup import CommonApiTest from coder_directory_api.engines import AuthEngine -from coder_directory_api.settings import SECRET_KEY from coder_directory_api.auth import make_token import json -import jwt -import datetime class LoginResourceTest(CommonApiTest): @@ -99,8 +96,8 @@ def test_refresh_token(self): ) self.assertEqual( - result.status_code, 200, + result.status_code, msg='Expected status code to be 200' ) @@ -111,18 +108,10 @@ def test_refresh_token(self): msg='Expected payload of 4 keys to return' ) - def test_refresh_expired_token(self): + def test_bad_token(self): """Test if POST request will bounce bad tokens""" dummy = json.loads(self.dummy) - - dummy['access_token'] = { - 'iss': 'coder directory', - 'user': dummy['user'], - 'exp': datetime.datetime.utcnow() - datetime.timedelta(minutes=5) - } - dummy['access_token'] = jwt.encode( - dummy['access_token'], SECRET_KEY - ).decode('utf-8') + dummy['refresh_token'] = None dummy = json.dumps(dummy) result = self.app.post(