From 3a3f9bececfeaa448009583b42813b4778d75fb0 Mon Sep 17 00:00:00 2001 From: Rebecca Law Date: Wed, 13 Jan 2016 09:25:46 +0000 Subject: [PATCH 1/8] Add api_token model and dao --- app/dao/api_tokens_dao.py | 17 +++++++++++++++++ app/models.py | 9 +++++++++ app/schemas.py | 6 ++++++ app/service/views/rest.py | 7 ++++++- tests/app/dao/test_tokens_dao.py | 16 ++++++++++++++++ 5 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 app/dao/api_tokens_dao.py create mode 100644 tests/app/dao/test_tokens_dao.py diff --git a/app/dao/api_tokens_dao.py b/app/dao/api_tokens_dao.py new file mode 100644 index 000000000..7a49ab5da --- /dev/null +++ b/app/dao/api_tokens_dao.py @@ -0,0 +1,17 @@ +from app import db +from app.models import ApiToken + + +def save_token_model(token, update_dict={}): + if update_dict: + del update_dict['id'] + db.session.query(ApiToken).filter_by(id=token.id).update(update_dict) + else: + db.session.add(token) + db.session.commit() + + +def get_model_api_tokens(token=None): + if token: + return ApiToken.query.filter_by(token=token).one() + return ApiToken.query.filter_by().all() diff --git a/app/models.py b/app/models.py index fd11d36d6..22efc2aea 100644 --- a/app/models.py +++ b/app/models.py @@ -61,6 +61,15 @@ class Service(db.Model): restricted = db.Column(db.Boolean, index=False, unique=False, nullable=False) +class ApiToken(db.Model): + __tablename__ = 'api_tokens' + + id = db.Column(db.Integer, primary_key=True) + token = db.Column(db.String, unique=True, nullable=False) + service_id = db.Column(db.Integer, db.ForeignKey('services.id'), index=True, nullable=False) + expiry_date = db.Column(db.DateTime) + + TEMPLATE_TYPES = ['sms', 'email', 'letter'] diff --git a/app/schemas.py b/app/schemas.py index a06fff497..07eb357b6 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -28,9 +28,15 @@ class TemplateSchema(ma.ModelSchema): exclude = ("updated_at", "created_at", "service_id") +class ApiTokenSchema(ma.ModelSchema): + class Meta: + model = models.ApiToken + user_schema = UserSchema() users_schema = UserSchema(many=True) service_schema = ServiceSchema() services_schema = ServiceSchema(many=True) +api_token_schema = ApiTokenSchema() +api_tokens_schema = ApiTokenSchema(many=True) template_schema = TemplateSchema() templates_schema = TemplateSchema(many=True) diff --git a/app/service/views/rest.py b/app/service/views/rest.py index 1df38bd15..b3d48d04a 100644 --- a/app/service/views/rest.py +++ b/app/service/views/rest.py @@ -5,7 +5,6 @@ from app.dao.services_dao import ( save_model_service, get_model_services, delete_model_service) from app.dao.templates_dao import ( save_model_template, get_model_templates) -from app.dao.users_dao import get_model_users from app.dao import DAOException from .. import service from app import db @@ -72,6 +71,12 @@ def get_service(service_id=None): return jsonify(data=data) +# TODO auth to be added +@service.route('//token', methods=['POST']) +def create_token(): + request.get_json() + + # TODO auth to be added. @service.route('//template/', methods=['POST']) def create_template(service_id): diff --git a/tests/app/dao/test_tokens_dao.py b/tests/app/dao/test_tokens_dao.py new file mode 100644 index 000000000..3c3d2fec7 --- /dev/null +++ b/tests/app/dao/test_tokens_dao.py @@ -0,0 +1,16 @@ +import uuid + +from app.dao import api_tokens_dao +from app.models import ApiToken + + +def test_should_create_token(notify_api, notify_db, notify_db_session, sample_service): + token = uuid.uuid4() + api_token = ApiToken(**{'token': token, 'service_id': sample_service.id}) + + api_tokens_dao.save_token_model(api_token) + + all_tokens = api_tokens_dao.get_model_api_tokens() + + assert len(all_tokens) == 1 + assert all_tokens[0].token == str(token) From 725b976d31596ec9d108a32a3a75ea1a9ffbc494 Mon Sep 17 00:00:00 2001 From: Rebecca Law Date: Wed, 13 Jan 2016 14:05:49 +0000 Subject: [PATCH 2/8] Created endpoints for create and delete token. --- app/dao/api_tokens_dao.py | 17 ------- app/dao/tokens_dao.py | 22 +++++++++ app/models.py | 4 +- app/schemas.py | 8 ++-- app/service/views/rest.py | 53 +++++++++++++++++++-- config.py | 5 +- migrations/versions/0002_create_tokens.py | 36 ++++++++++++++ requirements.txt | 1 + tests/app/dao/test_tokens_dao.py | 41 +++++++++++++--- tests/app/service/views/test_rest.py | 57 ++++++++++++++++++++++- 10 files changed, 210 insertions(+), 34 deletions(-) delete mode 100644 app/dao/api_tokens_dao.py create mode 100644 app/dao/tokens_dao.py create mode 100644 migrations/versions/0002_create_tokens.py diff --git a/app/dao/api_tokens_dao.py b/app/dao/api_tokens_dao.py deleted file mode 100644 index 7a49ab5da..000000000 --- a/app/dao/api_tokens_dao.py +++ /dev/null @@ -1,17 +0,0 @@ -from app import db -from app.models import ApiToken - - -def save_token_model(token, update_dict={}): - if update_dict: - del update_dict['id'] - db.session.query(ApiToken).filter_by(id=token.id).update(update_dict) - else: - db.session.add(token) - db.session.commit() - - -def get_model_api_tokens(token=None): - if token: - return ApiToken.query.filter_by(token=token).one() - return ApiToken.query.filter_by().all() diff --git a/app/dao/tokens_dao.py b/app/dao/tokens_dao.py new file mode 100644 index 000000000..e474ae9ee --- /dev/null +++ b/app/dao/tokens_dao.py @@ -0,0 +1,22 @@ +from app import db +from app.models import Token + + +def save_token_model(token, update_dict={}): + if update_dict: + del update_dict['id'] + db.session.query(Token).filter_by(id=token.id).update(update_dict) + else: + db.session.add(token) + db.session.commit() + + +def get_model_tokens(service_id=None): + if service_id: + return Token.query.filter_by(service_id=service_id).one() + return Token.query.filter_by().all() + + +def delete_model_token(token): + db.session.delete(token) + db.session.commit() diff --git a/app/models.py b/app/models.py index 22efc2aea..99e5902bc 100644 --- a/app/models.py +++ b/app/models.py @@ -61,8 +61,8 @@ class Service(db.Model): restricted = db.Column(db.Boolean, index=False, unique=False, nullable=False) -class ApiToken(db.Model): - __tablename__ = 'api_tokens' +class Token(db.Model): + __tablename__ = 'tokens' id = db.Column(db.Integer, primary_key=True) token = db.Column(db.String, unique=True, nullable=False) diff --git a/app/schemas.py b/app/schemas.py index 07eb357b6..47d2f0843 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -28,15 +28,15 @@ class TemplateSchema(ma.ModelSchema): exclude = ("updated_at", "created_at", "service_id") -class ApiTokenSchema(ma.ModelSchema): +class TokenSchema(ma.ModelSchema): class Meta: - model = models.ApiToken + model = models.Token user_schema = UserSchema() users_schema = UserSchema(many=True) service_schema = ServiceSchema() services_schema = ServiceSchema(many=True) -api_token_schema = ApiTokenSchema() -api_tokens_schema = ApiTokenSchema(many=True) template_schema = TemplateSchema() templates_schema = TemplateSchema(many=True) +token_schema = TokenSchema() +tokens_schema = TokenSchema(many=True) diff --git a/app/service/views/rest.py b/app/service/views/rest.py index b3d48d04a..d4325cf7e 100644 --- a/app/service/views/rest.py +++ b/app/service/views/rest.py @@ -1,13 +1,20 @@ -from flask import (jsonify, request) +import uuid +from datetime import datetime +from flask import (jsonify, request, current_app) from sqlalchemy.exc import DataError from sqlalchemy.orm.exc import NoResultFound from app.dao.services_dao import ( save_model_service, get_model_services, delete_model_service) +from app.dao.tokens_dao import (save_token_model, get_model_tokens, delete_model_token) +from app.dao.users_dao import get_model_users from app.dao.templates_dao import ( save_model_template, get_model_templates) from app.dao import DAOException from .. import service from app import db +from app.schemas import (services_schema, service_schema, token_schema) +from app.models import Token +from itsdangerous import URLSafeSerializer from app.schemas import ( services_schema, service_schema, template_schema, templates_schema) @@ -73,8 +80,48 @@ def get_service(service_id=None): # TODO auth to be added @service.route('//token', methods=['POST']) -def create_token(): - request.get_json() +def create_token(service_id=None): + try: + service = get_model_services(service_id=service_id) + except DataError: + return jsonify(result="error", message="Invalid service id"), 400 + except NoResultFound: + return jsonify(result="error", message="Service not found"), 404 + + token = _generate_token() + try: + try: + service_token = get_model_tokens(service_id=service_id) + save_token_model(service_token, update_dict={'id': service_token.id, + 'token': service_token.token, + 'expiry_date': datetime.now()}) + except NoResultFound: + pass + save_token_model(Token(service_id=service_id, token=token)) + except DAOException as e: + return jsonify(result='error', message=str(e)), 400 + return jsonify(token=str(token)), 201 + + +@service.route('//token', methods=['DELETE']) +def delete_token(service_id): + try: + token = get_model_tokens(service_id=service_id) + delete_model_token(token) + return jsonify(data=token_schema.dump(token).data), 202 + except NoResultFound: + return jsonify(result="error", message="Token not found"), 404 + + +def _generate_token(): + token = uuid.uuid4() + serializer = URLSafeSerializer(current_app.config.get('SECRET_KEY')) + return serializer.dumps(str(token), current_app.config.get('DANGEROUS_SALT')) + + +def _get_token(token): + serializer = URLSafeSerializer(current_app.config.get('SECRET_KEY')) + return serializer.loads(token, salt=current_app.config.get('DANGEROUS_SALT')) # TODO auth to be added. diff --git a/config.py b/config.py index 23cb9ad05..0efe7286c 100644 --- a/config.py +++ b/config.py @@ -1,4 +1,3 @@ - class Config(object): DEBUG = False NOTIFY_LOG_LEVEL = 'DEBUG' @@ -11,11 +10,15 @@ class Config(object): class Development(Config): DEBUG = True + SECRET_KEY = 'secret-key' + DANGEROUS_SALT = 'dangerous-salt' class Test(Config): DEBUG = True SQLALCHEMY_DATABASE_URI = 'postgresql://localhost/test_notification_api' + SECRET_KEY = 'secret-key' + DANGEROUS_SALT = 'dangerous-salt' class Live(Config): diff --git a/migrations/versions/0002_create_tokens.py b/migrations/versions/0002_create_tokens.py new file mode 100644 index 000000000..fa8e534e7 --- /dev/null +++ b/migrations/versions/0002_create_tokens.py @@ -0,0 +1,36 @@ +"""empty message + +Revision ID: 0002_create_api_token +Revises: 0001_initialise_data +Create Date: 2016-01-13 10:21:14.651691 + +""" + +# revision identifiers, used by Alembic. +revision = '0002_create_api_token' +down_revision = '0001_initialise_data' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.create_table('tokens', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('token', sa.String(), nullable=False), + sa.Column('service_id', sa.Integer(), nullable=False), + sa.Column('expiry_date', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['service_id'], ['services.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('token') + ) + op.create_index(op.f('ix_tokens_service_id'), 'tokens', ['service_id'], unique=False) + ### end Alembic commands ### + + +def downgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_tokens_service_id'), table_name='tokens') + op.drop_table('tokens') + ### end Alembic commands ### diff --git a/requirements.txt b/requirements.txt index c66faae28..5112e5b2f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,6 +9,7 @@ PyJWT==1.4.0 marshmallow==2.4.2 marshmallow-sqlalchemy==0.8.0 flask-marshmallow==0.6.2 +itsdangerous==0.24 git+https://github.com/alphagov/notifications-python-client.git@0.1.5#egg=notifications-python-client==0.1.5 diff --git a/tests/app/dao/test_tokens_dao.py b/tests/app/dao/test_tokens_dao.py index 3c3d2fec7..010d82208 100644 --- a/tests/app/dao/test_tokens_dao.py +++ b/tests/app/dao/test_tokens_dao.py @@ -1,16 +1,45 @@ import uuid +from app.dao import tokens_dao -from app.dao import api_tokens_dao -from app.models import ApiToken +from app.models import Token def test_should_create_token(notify_api, notify_db, notify_db_session, sample_service): token = uuid.uuid4() - api_token = ApiToken(**{'token': token, 'service_id': sample_service.id}) + api_token = Token(**{'token': token, 'service_id': sample_service.id}) - api_tokens_dao.save_token_model(api_token) - - all_tokens = api_tokens_dao.get_model_api_tokens() + tokens_dao.save_token_model(api_token) + all_tokens = tokens_dao.get_model_tokens() assert len(all_tokens) == 1 assert all_tokens[0].token == str(token) + + +def test_should_delete_api_token(notify_api, notify_db, notify_db_session, sample_service): + token = uuid.uuid4() + api_token = Token(**{'token': token, 'service_id': sample_service.id}) + tokens_dao.save_token_model(api_token) + all_tokens = tokens_dao.get_model_tokens() + assert len(all_tokens) == 1 + + tokens_dao.delete_model_token(all_tokens[0]) + empty_token_list = tokens_dao.get_model_tokens() + assert len(empty_token_list) == 0 + + +def test_should_return_token_for_service(notify_api, notify_db, notify_db_session, sample_service): + the_token = str(uuid.uuid4()) + api_token = Token(**{'token': the_token, 'service_id': sample_service.id}) + tokens_dao.save_token_model(api_token) + token = tokens_dao.get_model_tokens(sample_service.id) + assert token.service_id == sample_service.id + assert token.token == str(the_token) + + +def test_delete_model_token_should_remove_token(notify_api, notify_db, notify_db_session, sample_service): + api_token = Token(**{'token': str(uuid.uuid4()), 'service_id': sample_service.id}) + tokens_dao.save_token_model(api_token) + all_tokens = tokens_dao.get_model_tokens() + assert len(all_tokens) == 1 + tokens_dao.delete_model_token(all_tokens[0]) + assert len(tokens_dao.get_model_tokens()) == 0 diff --git a/tests/app/service/views/test_rest.py b/tests/app/service/views/test_rest.py index 6b971127a..c8448cef1 100644 --- a/tests/app/service/views/test_rest.py +++ b/tests/app/service/views/test_rest.py @@ -1,5 +1,5 @@ import json -from app.models import (Service, User, Template) +from app.models import (Service, User, Token, Template) from app.dao.services_dao import save_model_service from tests.app.conftest import sample_user as create_sample_user from flask import url_for @@ -263,6 +263,61 @@ def test_delete_service_not_exists(notify_api, notify_db, notify_db_session, sam assert Service.query.count() == 1 +def test_create_token_should_return_token_when_successful(notify_api, notify_db, notify_db_session, sample_service): + with notify_api.test_request_context(): + with notify_api.test_client() as client: + response = client.post(url_for('service.create_token', service_id=sample_service.id), + headers=[('Content-Type', 'application/json')]) + assert response.status_code == 201 + assert response.get_data is not None + saved_token = Token.query.first() + assert saved_token.service_id == sample_service.id + + +def test_create_token_should_expire_the_old_token_and_create_a_new_token(notify_api, notify_db, notify_db_session, + sample_service): + with notify_api.test_request_context(): + with notify_api.test_client() as client: + response = client.post(url_for('service.create_token', service_id=sample_service.id), + headers=[('Content-Type', 'application/json')]) + assert response.status_code == 201 + assert len(Token.query.all()) == 1 + saved_token = Token.query.first() + + response = client.post(url_for('service.create_token', service_id=sample_service.id), + headers=[('Content-Type', 'application/json')]) + assert response.status_code == 201 + all_tokens = Token.query.all() + assert len(all_tokens) == 2 + for x in all_tokens: + if x.id == saved_token.id: + assert x.expiry_date is not None + else: + assert x.expiry_date is None + assert x.token is not saved_token.token + + +def test_create_token_should_return_error_when_service_does_not_exist(notify_api, notify_db, notify_db_session, + sample_service): + with notify_api.test_request_context(): + with notify_api.test_client() as client: + response = client.post(url_for('service.create_token', service_id=123), + headers=[('Content-Type', 'application/json')]) + assert response.status_code == 404 + + +def test_delete_token(notify_api, notify_db, notify_db_session, sample_service): + with notify_api.test_request_context(): + with notify_api.test_client() as client: + client.post(url_for('service.create_token', service_id=sample_service.id), + headers=[('Content-Type', 'application/json')]) + all_tokens = Token.query.all() + assert len(all_tokens) == 1 + response = client.delete(url_for('service.delete_token', service_id=sample_service.id)) + assert response.status_code == 202 + assert len(Token.query.all()) == 0 + + def test_create_template(notify_api, notify_db, notify_db_session, sample_service): """ Tests POST endpoint '//template' a template can be created From a6dda26ba153c68f6484e4f427eb972173aecea2 Mon Sep 17 00:00:00 2001 From: Rebecca Law Date: Wed, 13 Jan 2016 14:34:45 +0000 Subject: [PATCH 3/8] Added test that the token can be signed and retrieved --- app/service/views/rest.py | 5 +++-- tests/app/service/views/test_rest.py | 9 +++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/app/service/views/rest.py b/app/service/views/rest.py index d4325cf7e..ab265d261 100644 --- a/app/service/views/rest.py +++ b/app/service/views/rest.py @@ -113,8 +113,9 @@ def delete_token(service_id): return jsonify(result="error", message="Token not found"), 404 -def _generate_token(): - token = uuid.uuid4() +def _generate_token(token=None): + if not token: + token = uuid.uuid4() serializer = URLSafeSerializer(current_app.config.get('SECRET_KEY')) return serializer.dumps(str(token), current_app.config.get('DANGEROUS_SALT')) diff --git a/tests/app/service/views/test_rest.py b/tests/app/service/views/test_rest.py index c8448cef1..57f6c9a59 100644 --- a/tests/app/service/views/test_rest.py +++ b/tests/app/service/views/test_rest.py @@ -318,6 +318,15 @@ def test_delete_token(notify_api, notify_db, notify_db_session, sample_service): assert len(Token.query.all()) == 0 +def test_token_generated_can_be_read_again(notify_api): + from app.service.views.rest import (_generate_token, _get_token) + import uuid + with notify_api.test_request_context(): + token = str(uuid.uuid4()) + signed_token = _generate_token(token=token) + assert token == _get_token(signed_token) + + def test_create_template(notify_api, notify_db, notify_db_session, sample_service): """ Tests POST endpoint '//template' a template can be created From 255dddaa27af0b8547cb189789e0474998b299f6 Mon Sep 17 00:00:00 2001 From: Rebecca Law Date: Wed, 13 Jan 2016 16:04:56 +0000 Subject: [PATCH 4/8] Changed the token endpoints so that you can renew a token and revoke a token. Updated create service so the token is created at that time --- app/dao/tokens_dao.py | 13 ++++----- app/service/views/rest.py | 42 +++++++++++++++------------ tests/app/dao/test_tokens_dao.py | 39 ++++++++++++++----------- tests/app/service/views/test_rest.py | 43 +++++++++++++++++++++------- 4 files changed, 85 insertions(+), 52 deletions(-) diff --git a/app/dao/tokens_dao.py b/app/dao/tokens_dao.py index e474ae9ee..74927f7d1 100644 --- a/app/dao/tokens_dao.py +++ b/app/dao/tokens_dao.py @@ -11,12 +11,11 @@ def save_token_model(token, update_dict={}): db.session.commit() -def get_model_tokens(service_id=None): +def get_model_tokens(service_id=None, raise_=True): if service_id: - return Token.query.filter_by(service_id=service_id).one() + # If expiry date is None the token is active + if raise_: + return Token.query.filter_by(service_id=service_id, expiry_date=None).one() + else: + return Token.query.filter_by(service_id=service_id, expiry_date=None).first() return Token.query.filter_by().all() - - -def delete_model_token(token): - db.session.delete(token) - db.session.commit() diff --git a/app/service/views/rest.py b/app/service/views/rest.py index ab265d261..6e03d7219 100644 --- a/app/service/views/rest.py +++ b/app/service/views/rest.py @@ -5,7 +5,7 @@ from sqlalchemy.exc import DataError from sqlalchemy.orm.exc import NoResultFound from app.dao.services_dao import ( save_model_service, get_model_services, delete_model_service) -from app.dao.tokens_dao import (save_token_model, get_model_tokens, delete_model_token) +from app.dao.tokens_dao import (save_token_model, get_model_tokens) from app.dao.users_dao import get_model_users from app.dao.templates_dao import ( save_model_template, get_model_templates) @@ -30,9 +30,11 @@ def create_service(): # db.session.commit try: save_model_service(service) + token = str(uuid.uuid4()) + save_token_model(Token(service_id=service.id, token=_generate_token(token))) except DAOException as e: return jsonify(result="error", message=str(e)), 400 - return jsonify(data=service_schema.dump(service).data), 201 + return jsonify(data=service_schema.dump(service).data, token=token), 201 # TODO auth to be added @@ -79,8 +81,8 @@ def get_service(service_id=None): # TODO auth to be added -@service.route('//token', methods=['POST']) -def create_token(service_id=None): +@service.route('//token/renew', methods=['POST']) +def renew_token(service_id=None): try: service = get_model_services(service_id=service_id) except DataError: @@ -90,27 +92,31 @@ def create_token(service_id=None): token = _generate_token() try: - try: - service_token = get_model_tokens(service_id=service_id) - save_token_model(service_token, update_dict={'id': service_token.id, - 'token': service_token.token, - 'expiry_date': datetime.now()}) - except NoResultFound: - pass + service_token = get_model_tokens(service_id=service_id, raise_=False) + if service_token: + # expire existing token + save_token_model(service_token, update_dict={'id': service_token.id, 'expiry_date': datetime.now()}) + # create a new one save_token_model(Token(service_id=service_id, token=token)) except DAOException as e: return jsonify(result='error', message=str(e)), 400 - return jsonify(token=str(token)), 201 + unsigned_token = str(_get_token(token)) + return jsonify(data=unsigned_token), 201 -@service.route('//token', methods=['DELETE']) -def delete_token(service_id): +@service.route('//token/revoke', methods=['POST']) +def revoke_token(service_id): try: - token = get_model_tokens(service_id=service_id) - delete_model_token(token) - return jsonify(data=token_schema.dump(token).data), 202 + service = get_model_services(service_id=service_id) + except DataError: + return jsonify(result="error", message="Invalid service id"), 400 except NoResultFound: - return jsonify(result="error", message="Token not found"), 404 + return jsonify(result="error", message="Service not found"), 404 + + service_token = get_model_tokens(service_id=service_id, raise_=False) + if service_token: + save_token_model(service_token, update_dict={'id': service_token.id, 'expiry_date': datetime.now()}) + return jsonify(data=token_schema.dump(service_token)), 202 def _generate_token(token=None): diff --git a/tests/app/dao/test_tokens_dao.py b/tests/app/dao/test_tokens_dao.py index 010d82208..e9a89a540 100644 --- a/tests/app/dao/test_tokens_dao.py +++ b/tests/app/dao/test_tokens_dao.py @@ -1,10 +1,13 @@ import uuid from app.dao import tokens_dao +from datetime import datetime from app.models import Token +from pytest import fail +from sqlalchemy.orm.exc import NoResultFound -def test_should_create_token(notify_api, notify_db, notify_db_session, sample_service): +def test_save_token_should_create_new_token(notify_api, notify_db, notify_db_session, sample_service): token = uuid.uuid4() api_token = Token(**{'token': token, 'service_id': sample_service.id}) @@ -15,16 +18,29 @@ def test_should_create_token(notify_api, notify_db, notify_db_session, sample_se assert all_tokens[0].token == str(token) -def test_should_delete_api_token(notify_api, notify_db, notify_db_session, sample_service): - token = uuid.uuid4() - api_token = Token(**{'token': token, 'service_id': sample_service.id}) +def test_save_token_should_update_the_token(notify_api, notify_db, notify_db_session, sample_service): + api_token = Token(**{'token': uuid.uuid4(), 'service_id': sample_service.id}) tokens_dao.save_token_model(api_token) + now = datetime.utcnow() + saved_token = tokens_dao.get_model_tokens(sample_service.id) + tokens_dao.save_token_model(saved_token, update_dict={'id': saved_token.id, 'expiry_date': now}) all_tokens = tokens_dao.get_model_tokens() assert len(all_tokens) == 1 + assert all_tokens[0].expiry_date == now - tokens_dao.delete_model_token(all_tokens[0]) - empty_token_list = tokens_dao.get_model_tokens() - assert len(empty_token_list) == 0 + +def test_get_token_should_raise_exception_when_service_does_not_exist(notify_api, notify_db, notify_db_session, + sample_service): + try: + tokens_dao.get_model_tokens(sample_service.id) + fail() + except NoResultFound: + pass + + +def test_get_token_should_return_none_when_service_does_not_exist(notify_api, notify_db, notify_db_session, + sample_service): + assert tokens_dao.get_model_tokens(service_id=sample_service.id, raise_=False) is None def test_should_return_token_for_service(notify_api, notify_db, notify_db_session, sample_service): @@ -34,12 +50,3 @@ def test_should_return_token_for_service(notify_api, notify_db, notify_db_sessio token = tokens_dao.get_model_tokens(sample_service.id) assert token.service_id == sample_service.id assert token.token == str(the_token) - - -def test_delete_model_token_should_remove_token(notify_api, notify_db, notify_db_session, sample_service): - api_token = Token(**{'token': str(uuid.uuid4()), 'service_id': sample_service.id}) - tokens_dao.save_token_model(api_token) - all_tokens = tokens_dao.get_model_tokens() - assert len(all_tokens) == 1 - tokens_dao.delete_model_token(all_tokens[0]) - assert len(tokens_dao.get_model_tokens()) == 0 diff --git a/tests/app/service/views/test_rest.py b/tests/app/service/views/test_rest.py index 57f6c9a59..7562e7cec 100644 --- a/tests/app/service/views/test_rest.py +++ b/tests/app/service/views/test_rest.py @@ -57,6 +57,7 @@ def test_post_service(notify_api, notify_db, notify_db_session, sample_user): json_resp = json.loads(resp.get_data(as_text=True)) assert json_resp['data']['name'] == service.name assert json_resp['data']['limit'] == service.limit + assert json_resp['token'] is not None def test_post_service_multiple_users(notify_api, notify_db, notify_db_session, sample_user): @@ -263,10 +264,11 @@ def test_delete_service_not_exists(notify_api, notify_db, notify_db_session, sam assert Service.query.count() == 1 -def test_create_token_should_return_token_when_successful(notify_api, notify_db, notify_db_session, sample_service): +def test_renew_token_should_return_token_when_service_does_not_have_a_valid_token(notify_api, notify_db, + notify_db_session, sample_service): with notify_api.test_request_context(): with notify_api.test_client() as client: - response = client.post(url_for('service.create_token', service_id=sample_service.id), + response = client.post(url_for('service.renew_token', service_id=sample_service.id), headers=[('Content-Type', 'application/json')]) assert response.status_code == 201 assert response.get_data is not None @@ -274,17 +276,17 @@ def test_create_token_should_return_token_when_successful(notify_api, notify_db, assert saved_token.service_id == sample_service.id -def test_create_token_should_expire_the_old_token_and_create_a_new_token(notify_api, notify_db, notify_db_session, - sample_service): +def test_renew_token_should_expire_the_old_token_and_create_a_new_token(notify_api, notify_db, notify_db_session, + sample_service): with notify_api.test_request_context(): with notify_api.test_client() as client: - response = client.post(url_for('service.create_token', service_id=sample_service.id), + response = client.post(url_for('service.renew_token', service_id=sample_service.id), headers=[('Content-Type', 'application/json')]) assert response.status_code == 201 assert len(Token.query.all()) == 1 saved_token = Token.query.first() - response = client.post(url_for('service.create_token', service_id=sample_service.id), + response = client.post(url_for('service.renew_token', service_id=sample_service.id), headers=[('Content-Type', 'application/json')]) assert response.status_code == 201 all_tokens = Token.query.all() @@ -301,21 +303,40 @@ def test_create_token_should_return_error_when_service_does_not_exist(notify_api sample_service): with notify_api.test_request_context(): with notify_api.test_client() as client: - response = client.post(url_for('service.create_token', service_id=123), + response = client.post(url_for('service.renew_token', service_id=123), headers=[('Content-Type', 'application/json')]) assert response.status_code == 404 -def test_delete_token(notify_api, notify_db, notify_db_session, sample_service): +def test_revoke_token_should_expire_token_for_service(notify_api, notify_db, notify_db_session, sample_service): with notify_api.test_request_context(): with notify_api.test_client() as client: - client.post(url_for('service.create_token', service_id=sample_service.id), + client.post(url_for('service.renew_token', service_id=sample_service.id), headers=[('Content-Type', 'application/json')]) + assert len(Token.query.all()) == 1 + response = client.post(url_for('service.revoke_token', service_id=sample_service.id)) + assert response.status_code == 202 all_tokens = Token.query.all() assert len(all_tokens) == 1 - response = client.delete(url_for('service.delete_token', service_id=sample_service.id)) - assert response.status_code == 202 + assert all_tokens[0].expiry_date is not None + + +def test_create_service_should_create_new_token_for_service(notify_api, notify_db, notify_db_session, sample_user): + with notify_api.test_request_context(): + with notify_api.test_client() as client: + data = { + 'name': 'created service', + 'users': [sample_user.id], + 'limit': 1000, + 'restricted': False, + 'active': False} + headers = [('Content-Type', 'application/json')] assert len(Token.query.all()) == 0 + resp = client.post(url_for('service.create_service'), + data=json.dumps(data), + headers=headers) + assert resp.status_code == 201 + assert len(Token.query.all()) == 1 def test_token_generated_can_be_read_again(notify_api): From 4a692d555e4bed0d7ce24fefa19521ef6d88b7e1 Mon Sep 17 00:00:00 2001 From: Rebecca Law Date: Wed, 13 Jan 2016 17:08:57 +0000 Subject: [PATCH 5/8] Create a relationship between services and tokens --- app/dao/services_dao.py | 1 + app/models.py | 3 ++- app/schemas.py | 2 +- .../{0002_create_tokens.py => 0003_create_tokens.py} | 8 ++++---- 4 files changed, 8 insertions(+), 6 deletions(-) rename migrations/versions/{0002_create_tokens.py => 0003_create_tokens.py} (84%) diff --git a/app/dao/services_dao.py b/app/dao/services_dao.py index 54fb5dd01..8cbf8d518 100644 --- a/app/dao/services_dao.py +++ b/app/dao/services_dao.py @@ -7,6 +7,7 @@ from app.models import (Service, User) def save_model_service(service, update_dict=None): + print(update_dict) users_list = update_dict.get('users', []) if update_dict else getattr(service, 'users', []) if not users_list: error_msg = {'users': ['Missing data for required attribute']} diff --git a/app/models.py b/app/models.py index 99e5902bc..0e4b13609 100644 --- a/app/models.py +++ b/app/models.py @@ -65,8 +65,9 @@ class Token(db.Model): __tablename__ = 'tokens' id = db.Column(db.Integer, primary_key=True) - token = db.Column(db.String, unique=True, nullable=False) + token = db.Column(db.String(255), unique=True, nullable=False) service_id = db.Column(db.Integer, db.ForeignKey('services.id'), index=True, nullable=False) + service = db.relationship('Service', backref=db.backref('tokens', lazy='dynamic')) expiry_date = db.Column(db.DateTime) diff --git a/app/schemas.py b/app/schemas.py index 47d2f0843..f19847972 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -19,7 +19,7 @@ class UserSchema(ma.ModelSchema): class ServiceSchema(ma.ModelSchema): class Meta: model = models.Service - exclude = ("updated_at", "created_at", "templates") + exclude = ("updated_at", "created_at", "tokens", "templates") class TemplateSchema(ma.ModelSchema): diff --git a/migrations/versions/0002_create_tokens.py b/migrations/versions/0003_create_tokens.py similarity index 84% rename from migrations/versions/0002_create_tokens.py rename to migrations/versions/0003_create_tokens.py index fa8e534e7..7fdb034bb 100644 --- a/migrations/versions/0002_create_tokens.py +++ b/migrations/versions/0003_create_tokens.py @@ -1,13 +1,13 @@ """empty message -Revision ID: 0002_create_api_token +Revision ID: 0003_create_tokens Revises: 0001_initialise_data -Create Date: 2016-01-13 10:21:14.651691 +Create Date: 2016-01-13 17:07:49.061776 """ # revision identifiers, used by Alembic. -revision = '0002_create_api_token' +revision = '0003_create_tokens' down_revision = '0001_initialise_data' from alembic import op @@ -18,7 +18,7 @@ def upgrade(): ### commands auto generated by Alembic - please adjust! ### op.create_table('tokens', sa.Column('id', sa.Integer(), nullable=False), - sa.Column('token', sa.String(), nullable=False), + sa.Column('token', sa.String(length=255), nullable=False), sa.Column('service_id', sa.Integer(), nullable=False), sa.Column('expiry_date', sa.DateTime(), nullable=True), sa.ForeignKeyConstraint(['service_id'], ['services.id'], ), From cf730de927447894fe73fd580b704a78e029cc55 Mon Sep 17 00:00:00 2001 From: Rebecca Law Date: Wed, 13 Jan 2016 17:12:30 +0000 Subject: [PATCH 6/8] Remove print statement --- app/dao/services_dao.py | 1 - 1 file changed, 1 deletion(-) diff --git a/app/dao/services_dao.py b/app/dao/services_dao.py index 8cbf8d518..54fb5dd01 100644 --- a/app/dao/services_dao.py +++ b/app/dao/services_dao.py @@ -7,7 +7,6 @@ from app.models import (Service, User) def save_model_service(service, update_dict=None): - print(update_dict) users_list = update_dict.get('users', []) if update_dict else getattr(service, 'users', []) if not users_list: error_msg = {'users': ['Missing data for required attribute']} From 5e61515eb6d07004ae2d3eb8f8d7ffe59b351a8c Mon Sep 17 00:00:00 2001 From: Rebecca Law Date: Thu, 14 Jan 2016 10:23:06 +0000 Subject: [PATCH 7/8] Fix the migration script so that the down revision is pointing to the migration scripts in the merge --- migrations/versions/0003_create_tokens.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/migrations/versions/0003_create_tokens.py b/migrations/versions/0003_create_tokens.py index 7fdb034bb..b850980e3 100644 --- a/migrations/versions/0003_create_tokens.py +++ b/migrations/versions/0003_create_tokens.py @@ -8,7 +8,7 @@ Create Date: 2016-01-13 17:07:49.061776 # revision identifiers, used by Alembic. revision = '0003_create_tokens' -down_revision = '0001_initialise_data' +down_revision = '0002_add_templates' from alembic import op import sqlalchemy as sa From 436f45b70dbdb852c08a0eb0fd3a172867e617d8 Mon Sep 17 00:00:00 2001 From: Rebecca Law Date: Thu, 14 Jan 2016 11:30:45 +0000 Subject: [PATCH 8/8] Moved the _generate_token methods to the tokens_dao. --- app/dao/tokens_dao.py | 32 ++++++++++++++- app/schemas.py | 2 + app/service/views/rest.py | 52 +++++++++---------------- tests/app/conftest.py | 18 ++++++++- tests/app/dao/test_tokens_dao.py | 58 ++++++++++++++++------------ tests/app/service/views/test_rest.py | 23 ++++------- 6 files changed, 108 insertions(+), 77 deletions(-) diff --git a/app/dao/tokens_dao.py b/app/dao/tokens_dao.py index 74927f7d1..54a5248d9 100644 --- a/app/dao/tokens_dao.py +++ b/app/dao/tokens_dao.py @@ -1,17 +1,25 @@ +from flask import current_app +from itsdangerous import URLSafeSerializer + from app import db from app.models import Token -def save_token_model(token, update_dict={}): +def save_model_token(token, update_dict={}): if update_dict: del update_dict['id'] db.session.query(Token).filter_by(id=token.id).update(update_dict) else: + token.token = _generate_token() db.session.add(token) db.session.commit() def get_model_tokens(service_id=None, raise_=True): + """ + :param raise_: when True query tokens using one() which will raise NoResultFound exception + when False query tokens usong first() which will return None and not raise an exception. + """ if service_id: # If expiry date is None the token is active if raise_: @@ -19,3 +27,25 @@ def get_model_tokens(service_id=None, raise_=True): else: return Token.query.filter_by(service_id=service_id, expiry_date=None).first() return Token.query.filter_by().all() + + +def get_unsigned_token(service_id): + """ + There should only be one valid token for each service. + This method can only be exposed to the Authentication of the api calls. + """ + token = Token.query.filter_by(service_id=service_id, expiry_date=None).one() + return _get_token(token.token) + + +def _generate_token(token=None): + import uuid + if not token: + token = uuid.uuid4() + serializer = URLSafeSerializer(current_app.config.get('SECRET_KEY')) + return serializer.dumps(str(token), current_app.config.get('DANGEROUS_SALT')) + + +def _get_token(token): + serializer = URLSafeSerializer(current_app.config.get('SECRET_KEY')) + return serializer.loads(token, salt=current_app.config.get('DANGEROUS_SALT')) diff --git a/app/schemas.py b/app/schemas.py index f19847972..71bc14377 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -31,6 +31,8 @@ class TemplateSchema(ma.ModelSchema): class TokenSchema(ma.ModelSchema): class Meta: model = models.Token + exclude = ["service"] + user_schema = UserSchema() users_schema = UserSchema(many=True) diff --git a/app/service/views/rest.py b/app/service/views/rest.py index 6e03d7219..ffd570d7e 100644 --- a/app/service/views/rest.py +++ b/app/service/views/rest.py @@ -1,22 +1,20 @@ -import uuid from datetime import datetime -from flask import (jsonify, request, current_app) + +from flask import (jsonify, request) from sqlalchemy.exc import DataError from sqlalchemy.orm.exc import NoResultFound + +from app import db +from app.dao import DAOException from app.dao.services_dao import ( save_model_service, get_model_services, delete_model_service) -from app.dao.tokens_dao import (save_token_model, get_model_tokens) -from app.dao.users_dao import get_model_users from app.dao.templates_dao import ( - save_model_template, get_model_templates) -from app.dao import DAOException -from .. import service -from app import db -from app.schemas import (services_schema, service_schema, token_schema) + save_model_template, get_model_templates, delete_model_template) +from app.dao.tokens_dao import (save_model_token, get_model_tokens, get_unsigned_token) from app.models import Token -from itsdangerous import URLSafeSerializer from app.schemas import ( - services_schema, service_schema, template_schema, templates_schema) + services_schema, service_schema, template_schema) +from .. import service # TODO auth to be added. @@ -30,11 +28,10 @@ def create_service(): # db.session.commit try: save_model_service(service) - token = str(uuid.uuid4()) - save_token_model(Token(service_id=service.id, token=_generate_token(token))) + save_model_token(Token(service_id=service.id)) except DAOException as e: return jsonify(result="error", message=str(e)), 400 - return jsonify(data=service_schema.dump(service).data, token=token), 201 + return jsonify(data=service_schema.dump(service).data, token=get_unsigned_token(service.id)), 201 # TODO auth to be added @@ -84,30 +81,29 @@ def get_service(service_id=None): @service.route('//token/renew', methods=['POST']) def renew_token(service_id=None): try: - service = get_model_services(service_id=service_id) + get_model_services(service_id=service_id) except DataError: return jsonify(result="error", message="Invalid service id"), 400 except NoResultFound: return jsonify(result="error", message="Service not found"), 404 - token = _generate_token() try: service_token = get_model_tokens(service_id=service_id, raise_=False) if service_token: # expire existing token - save_token_model(service_token, update_dict={'id': service_token.id, 'expiry_date': datetime.now()}) + save_model_token(service_token, update_dict={'id': service_token.id, 'expiry_date': datetime.now()}) # create a new one - save_token_model(Token(service_id=service_id, token=token)) + save_model_token(Token(service_id=service_id)) except DAOException as e: return jsonify(result='error', message=str(e)), 400 - unsigned_token = str(_get_token(token)) + unsigned_token = get_unsigned_token(service_id) return jsonify(data=unsigned_token), 201 @service.route('//token/revoke', methods=['POST']) def revoke_token(service_id): try: - service = get_model_services(service_id=service_id) + get_model_services(service_id=service_id) except DataError: return jsonify(result="error", message="Invalid service id"), 400 except NoResultFound: @@ -115,20 +111,8 @@ def revoke_token(service_id): service_token = get_model_tokens(service_id=service_id, raise_=False) if service_token: - save_token_model(service_token, update_dict={'id': service_token.id, 'expiry_date': datetime.now()}) - return jsonify(data=token_schema.dump(service_token)), 202 - - -def _generate_token(token=None): - if not token: - token = uuid.uuid4() - serializer = URLSafeSerializer(current_app.config.get('SECRET_KEY')) - return serializer.dumps(str(token), current_app.config.get('DANGEROUS_SALT')) - - -def _get_token(token): - serializer = URLSafeSerializer(current_app.config.get('SECRET_KEY')) - return serializer.loads(token, salt=current_app.config.get('DANGEROUS_SALT')) + save_model_token(service_token, update_dict={'id': service_token.id, 'expiry_date': datetime.now()}) + return jsonify(), 202 # TODO auth to be added. diff --git a/tests/app/conftest.py b/tests/app/conftest.py index b2d8b92e0..560e3bf38 100644 --- a/tests/app/conftest.py +++ b/tests/app/conftest.py @@ -1,8 +1,9 @@ import pytest -from app.models import (User, Service, Template) -from app.dao.users_dao import (save_model_user, get_model_users) +from app.models import (User, Service, Template, Token) +from app.dao.users_dao import (save_model_user) from app.dao.services_dao import save_model_service from app.dao.templates_dao import save_model_template +from app.dao.tokens_dao import save_model_token @pytest.fixture(scope='function') @@ -50,3 +51,16 @@ def sample_template(notify_db, template = Template(**data) save_model_template(template) return template + + +@pytest.fixture(scope='function') +def sample_token(notify_db, + notify_db_session, + service=None): + import uuid + if service is None: + service = sample_service(notify_db, notify_db_session) + data = {'service_id': service.id} + token = Token(**data) + save_model_token(token) + return token diff --git a/tests/app/dao/test_tokens_dao.py b/tests/app/dao/test_tokens_dao.py index e9a89a540..f7747367e 100644 --- a/tests/app/dao/test_tokens_dao.py +++ b/tests/app/dao/test_tokens_dao.py @@ -1,52 +1,62 @@ import uuid -from app.dao import tokens_dao +from app.dao.tokens_dao import (save_model_token, get_model_tokens, get_unsigned_token, _generate_token, _get_token) from datetime import datetime - from app.models import Token from pytest import fail from sqlalchemy.orm.exc import NoResultFound +def test_token_is_signed_and_can_be_read_again(notify_api): + import uuid + with notify_api.test_request_context(): + token = str(uuid.uuid4()) + signed_token = _generate_token(token=token) + assert token == _get_token(signed_token) + assert signed_token != token + + def test_save_token_should_create_new_token(notify_api, notify_db, notify_db_session, sample_service): - token = uuid.uuid4() - api_token = Token(**{'token': token, 'service_id': sample_service.id}) + api_token = Token(**{'service_id': sample_service.id}) + save_model_token(api_token) - tokens_dao.save_token_model(api_token) - - all_tokens = tokens_dao.get_model_tokens() + all_tokens = get_model_tokens() assert len(all_tokens) == 1 - assert all_tokens[0].token == str(token) + assert all_tokens[0] == api_token -def test_save_token_should_update_the_token(notify_api, notify_db, notify_db_session, sample_service): - api_token = Token(**{'token': uuid.uuid4(), 'service_id': sample_service.id}) - tokens_dao.save_token_model(api_token) +def test_save_token_should_update_the_token(notify_api, notify_db, notify_db_session, sample_token): now = datetime.utcnow() - saved_token = tokens_dao.get_model_tokens(sample_service.id) - tokens_dao.save_token_model(saved_token, update_dict={'id': saved_token.id, 'expiry_date': now}) - all_tokens = tokens_dao.get_model_tokens() + saved_token = get_model_tokens(sample_token.service_id) + save_model_token(saved_token, update_dict={'id': saved_token.id, 'expiry_date': now}) + all_tokens = get_model_tokens() assert len(all_tokens) == 1 assert all_tokens[0].expiry_date == now + assert all_tokens[0].token == saved_token.token + assert all_tokens[0].id == saved_token.id + assert all_tokens[0].service_id == saved_token.service_id def test_get_token_should_raise_exception_when_service_does_not_exist(notify_api, notify_db, notify_db_session, sample_service): try: - tokens_dao.get_model_tokens(sample_service.id) - fail() + get_model_tokens(sample_service.id) + fail("Should have thrown a NoResultFound exception") except NoResultFound: pass def test_get_token_should_return_none_when_service_does_not_exist(notify_api, notify_db, notify_db_session, sample_service): - assert tokens_dao.get_model_tokens(service_id=sample_service.id, raise_=False) is None + assert get_model_tokens(service_id=sample_service.id, raise_=False) is None -def test_should_return_token_for_service(notify_api, notify_db, notify_db_session, sample_service): - the_token = str(uuid.uuid4()) - api_token = Token(**{'token': the_token, 'service_id': sample_service.id}) - tokens_dao.save_token_model(api_token) - token = tokens_dao.get_model_tokens(sample_service.id) - assert token.service_id == sample_service.id - assert token.token == str(the_token) +def test_should_return_token_for_service(notify_api, notify_db, notify_db_session, sample_token): + token = get_model_tokens(sample_token.service_id) + assert token == sample_token + + +def test_should_return_unsigned_token_for_service_id(notify_api, notify_db, notify_db_session, + sample_token): + unsigned_token = get_unsigned_token(sample_token.service_id) + assert sample_token.token != unsigned_token + assert unsigned_token == _get_token(sample_token.token) diff --git a/tests/app/service/views/test_rest.py b/tests/app/service/views/test_rest.py index 7562e7cec..3b4f4f018 100644 --- a/tests/app/service/views/test_rest.py +++ b/tests/app/service/views/test_rest.py @@ -1,9 +1,11 @@ import json -from app.models import (Service, User, Token, Template) -from app.dao.services_dao import save_model_service -from tests.app.conftest import sample_user as create_sample_user + from flask import url_for +from app.dao.services_dao import save_model_service +from app.models import (Service, User, Token, Template) +from tests.app.conftest import sample_user as create_sample_user + def test_get_service_list(notify_api, notify_db, notify_db_session, sample_service): """ @@ -308,13 +310,11 @@ def test_create_token_should_return_error_when_service_does_not_exist(notify_api assert response.status_code == 404 -def test_revoke_token_should_expire_token_for_service(notify_api, notify_db, notify_db_session, sample_service): +def test_revoke_token_should_expire_token_for_service(notify_api, notify_db, notify_db_session, sample_token): with notify_api.test_request_context(): with notify_api.test_client() as client: - client.post(url_for('service.renew_token', service_id=sample_service.id), - headers=[('Content-Type', 'application/json')]) assert len(Token.query.all()) == 1 - response = client.post(url_for('service.revoke_token', service_id=sample_service.id)) + response = client.post(url_for('service.revoke_token', service_id=sample_token.service_id)) assert response.status_code == 202 all_tokens = Token.query.all() assert len(all_tokens) == 1 @@ -339,15 +339,6 @@ def test_create_service_should_create_new_token_for_service(notify_api, notify_d assert len(Token.query.all()) == 1 -def test_token_generated_can_be_read_again(notify_api): - from app.service.views.rest import (_generate_token, _get_token) - import uuid - with notify_api.test_request_context(): - token = str(uuid.uuid4()) - signed_token = _generate_token(token=token) - assert token == _get_token(signed_token) - - def test_create_template(notify_api, notify_db, notify_db_session, sample_service): """ Tests POST endpoint '//template' a template can be created