Merged in issue-30-site-management (pull request #24)

Issue 30 site management

* Initial steps in testing setup of site.

* Build out initial tests for accessing a site.

* Update view routes for retrieving the sites.

* Change linkage of site components to the site.
    Create ability to create a test site.

* Add links and self link to the site response.

* Break site components in blocks and tokens.

* Fix fetching of an existing site.

* Add additional tests to test site access against different types of users.

* Add support for site deletion.

* Add support for site creation.

* Build out support for site modification.

* Fix issue with testing of modified sites.

* Improve readability of test data and tests.

Approved-by: Dorian Pula <dorian.pula@amber-penguin-software.ca>
This commit is contained in:
Dorian 2017-04-05 21:43:24 +00:00
parent 4d689b2f3a
commit 499724732f
22 changed files with 713 additions and 137 deletions

View File

@ -14,7 +14,7 @@ test: build test-api test-ui
# Runs API tests # Runs API tests
test-api: stop build test-api: stop build
docker-compose run --no-deps rookeries inv test.python_style docker-compose run --no-deps rookeries inv test.python_style
docker-compose up -d docker-compose up -d db rookeries
docker-compose run rookeries inv db.wait docker-compose run rookeries inv db.wait
docker-compose run rookeries inv db.init docker-compose run rookeries inv db.init
docker-compose run rookeries inv db.upgrade docker-compose run rookeries inv db.upgrade

View File

@ -16,7 +16,6 @@ services:
testing: testing:
image: selenium/standalone-firefox-debug image: selenium/standalone-firefox-debug
ports: ports:
- "4444:4444"
- "5900:5900" - "5900:5900"
db: db:

View File

View File

@ -0,0 +1,58 @@
"""
Models for blocks.
:copyright: Copyright 2013-2017, Dorian Pula <dorian.pula@amber-penguin-software.ca>
:license: AGPL v3+
"""
import enum
from rookeries.database import db
class SupportedBlockTypes(enum.Enum):
MENU = 'menu'
MARKDOWN = 'markdown'
class Block(db.Model):
__tablename__ = 'block'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String)
site_id = db.Column(db.Integer, db.ForeignKey('site.id'))
site = db.relationship('Site')
type_ = db.Column(db.Enum(SupportedBlockTypes))
function_ = db.Column(db.String) # TODO: Figure out how to declare function of a block... menu, header, etc.
# TODO: Add in to_json of implementing type...
class MenuBlock(db.Model):
__tablename__ = 'menu_blocks'
id = db.Column(db.Integer, primary_key=True)
orderinal = db.Column(db.Integer)
title = db.Column(db.String)
page_id = db.Column(db.Integer, db.ForeignKey('page.id'))
page = db.relationship('Page')
def __init__(self, title, site, page, orderinal=0):
self.title = title
self.site = site
self.page = page
self.orderinal = orderinal
def __repr__(self) -> str:
return f'<Menu {self.orderinal} - {self.title}>'
def to_json(self) -> dict:
return {
'title': self.title,
'orderinal': self.orderinal,
'page': self.page.slug,
# TODO: Improve this into something more regular.
'url': '/' + self.page.slug,
}

31
rookeries/blocks/views.py Normal file
View File

@ -0,0 +1,31 @@
"""
Views for managing blocks of content.
:copyright: Copyright 2013-2017, Dorian Pula <dorian.pula@amber-penguin-software.ca>
:license: AGPL v3+
"""
import logging
import flask
from rookeries.main import rookeries_app
from rookeries.blocks import models
logger = logging.getLogger(__name__)
# TODO: Add in functionality to create and update menus.
def add_self_link(block_json):
block_json['urls']['self'] = flask.url_for('rookeries_app.get_site', site_name=block_json['name'], _external=True)
return block_json
@rookeries_app.route('/api/sites/<site_name>/menu', methods=['GET'])
def menu_config(site_name):
# TODO: Migrate to a component/block instead of being part of the site directly.
site = models.Block.query.filter_by(name=site_name).first_or_404()
# TODO: Then figure out either menu setup... or how to generate blocks of content in a generic fashion.
nav_menu = {'menu': [menu_item.to_json() for menu_item in site.menu]}
return flask.jsonify(nav_menu)

View File

@ -18,6 +18,8 @@ class DefaultConfig(object):
# Database connection string. # Database connection string.
SQLALCHEMY_DATABASE_URI = 'postgresql+psycopg2://admin:password@localhost:5432/rookeries' SQLALCHEMY_DATABASE_URI = 'postgresql+psycopg2://admin:password@localhost:5432/rookeries'
SQLALCHEMY_TRACK_MODIFICATIONS = False
SQLALCHEMY_ECHO = False
# Security config # Security config
# Key used by Flask for session secrets. # Key used by Flask for session secrets.

View File

@ -99,7 +99,7 @@ def error_message_from_exception(error: Exception):
error_message = { error_message = {
'error': { 'error': {
'status_code': http.HTTPStatus.INTERNAL_SERVER_ERROR, 'status_code': http.HTTPStatus.INTERNAL_SERVER_ERROR,
'message': f'{error}', 'message': f'{type(error)} with {error.args}: {error}',
}, },
} }
return flask.jsonify(error_message), http.HTTPStatus.INTERNAL_SERVER_ERROR return flask.jsonify(error_message), http.HTTPStatus.INTERNAL_SERVER_ERROR

View File

@ -16,11 +16,13 @@ rookeries_app = flask.Blueprint('rookeries_app', __name__)
def register_views(): def register_views():
"""Import the various views in the app.""" """Import the various views in the app."""
rookeries_views = [ rookeries_views = [
'rookeries.views', 'rookeries.blocks.views',
'rookeries.pages.views',
'rookeries.security',
'rookeries.sites.views', 'rookeries.sites.views',
'rookeries.security',
'rookeries.pages.views',
'rookeries.tokens.views',
'rookeries.users.views', 'rookeries.users.views',
'rookeries.views',
] ]
for views_modules in rookeries_views: for views_modules in rookeries_views:
importlib.import_module(views_modules) importlib.import_module(views_modules)

View File

@ -5,9 +5,7 @@ Models for sites.
:license: AGPL v3+ :license: AGPL v3+
""" """
import uuid from sqlalchemy_utils.types import url as sql_url
from sqlalchemy_utils.types import url as sql_url, uuid as sql_uuid
from rookeries.database import db from rookeries.database import db
@ -16,59 +14,32 @@ class Site(db.Model):
__tablename__ = 'site' __tablename__ = 'site'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
api_key = db.Column(sql_uuid.UUIDType, unique=True, nullable=False) name = db.Column(db.String(64), unique=True, nullable=False)
name = db.Column(db.String(64)) base_url = db.Column(sql_url.URLType)
url = db.Column(sql_url.URLType)
# TODO: Add in tag line, copyright notice, favicon and logo image, other attributes in here. # TODO: Add in relations for blocks and a landing page as well...
config = db.Column(db.JSON) # blocks = db.relationship('Block', back_populates='site')
menu = db.relationship('SiteMenu', order_by='SiteMenu.orderinal', back_populates='site') # landing_page = db.Column('Page', back_populates='site')
def __init__(self, name, url, config=None, api_key=None): def __init__(self, name, url):
self.name = name self.name = name
self.url = url self.base_url = url
self.config = config or {}
self.api_key = api_key or uuid.uuid4()
def __repr__(self) -> str: def __repr__(self) -> str:
return f'<Site {self.name} - {self.url}>' return f'<Site {self.name} - {self.url}>'
@staticmethod
def from_json(json_request):
site_name = json_request['name']
site_base_url = json_request['base_url']
return Site(name=site_name, url=site_base_url)
def to_json(self) -> dict: def to_json(self) -> dict:
# TODO: Add in links to the site...
# site_urls = {block.function_: block.id for block in self.blocks}
# site_urls['landing_page'] = f'/api/sites/{self.name}/pages/landing_page'
return { return {
'api_key': self.api_key,
'name': self.name, 'name': self.name,
'url': self.url, 'base_url': self.base_url,
'config': self.config,
}
class SiteMenu(db.Model):
__tablename__ = 'site_menu'
id = db.Column(db.Integer, primary_key=True)
orderinal = db.Column(db.Integer)
title = db.Column(db.String)
site_id = db.Column(db.Integer, db.ForeignKey('site.id'))
site = db.relationship('Site', back_populates='menu')
page_id = db.Column(db.Integer, db.ForeignKey('page.id'))
page = db.relationship('Page')
def __init__(self, title, site, page, orderinal=0):
self.title = title
self.site = site
self.page = page
self.orderinal = orderinal
def __repr__(self) -> str:
return f'<SiteMenu {self.orderinal} - {self.title}>'
def to_json(self) -> dict:
return {
'title': self.title,
'orderinal': self.orderinal,
'page': self.page.slug,
# TODO: Improve this into something more regular.
'url': '/' + self.page.slug,
} }

23
rookeries/sites/schema.py Normal file
View File

@ -0,0 +1,23 @@
"""
JSON schema for sites.
:copyright: Copyright 2013-2017, Dorian Pula <dorian.pula@amber-penguin-software.ca>
:license: AGPL v3+
"""
SITE_CREATION_MODIFICATION_SCHEMA = {
'type': 'object',
'required': ['name', 'base_url'],
'properties': {
'name': {
'type': 'string',
'minLength': 1,
'maxLength': 64,
},
'base_url': {
'type': 'string',
'minLength': 8,
},
}
}

View File

@ -5,33 +5,107 @@ Views for managing sites.
:license: AGPL v3+ :license: AGPL v3+
""" """
import http
import logging import logging
import flask import flask
import jsonschema
from rookeries import errors
from rookeries.database import db
from rookeries.main import rookeries_app from rookeries.main import rookeries_app
from rookeries.sites import models from rookeries.sites import models, schema
from rookeries.users import models as user_models
from rookeries.vendors import flask_jwt
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
ROOKERIES_API_KEY_HEADER = 'ROOKERIES_API_KEY'
# TODO: Add in functionality to create and updates sites and menus. # TODO: Add in functionality to create and update sites.
# TODO: Add in ability to query on collections of sites.
@rookeries_app.route('/api/site', methods=['GET']) def add_self_link(site_json):
def app_config(): # TODO: Consider creating a better way to resolve IDs in links to resources, in a generic manner.
site_json['urls'] = {
# Get API Key from header 'self': flask.url_for('rookeries_app.get_site', site_name=site_json['name'], _external=True),
api_key = flask.request.headers.get(ROOKERIES_API_KEY_HEADER) or flask.current_app.config['DEFAULT_SITE'] }
site = models.Site.query.filter_by(api_key=api_key).first_or_404() return site_json
return flask.jsonify(site.to_json())
@rookeries_app.route('/api/site/menu', methods=['GET']) @rookeries_app.route('/api/sites/', methods=['POST'])
def menu_config(): @flask_jwt.jwt_required()
def create_site():
# Check if request is JSON, and respects the JSON schema
if not flask.request.is_json:
flask.abort(http.HTTPStatus.BAD_REQUEST)
# Get API Key from header incoming_request = flask.request.get_json()
api_key = flask.request.headers.get(ROOKERIES_API_KEY_HEADER) or flask.current_app.config['DEFAULT_SITE'] jsonschema.validate(incoming_request, schema.SITE_CREATION_MODIFICATION_SCHEMA)
site = models.Site.query.filter_by(api_key=api_key).first_or_404()
nav_menu = {'menu': [menu_item.to_json() for menu_item in site.menu]} # Check if user allowed to create a site.
return flask.jsonify(nav_menu) current_user = flask_jwt.current_identity
requesting_user_role = user_models.UserRole[current_user['role']]
if requesting_user_role != user_models.UserRole.admin:
flask.abort(http.HTTPStatus.UNAUTHORIZED)
# Creates a site from the json if no site exists with the given sitename.
if models.Site.query.filter_by(name=incoming_request['name']).first():
raise errors.ConflictingResourceError('Existing resource already found. PUT to the resource to update it.')
site = models.Site.from_json(incoming_request)
db.session.add(site)
db.session.commit()
site_response = add_self_link(site.to_json())
return flask.jsonify(site_response), http.HTTPStatus.CREATED
@rookeries_app.route('/api/sites/<site_name>', methods=['PUT'])
@flask_jwt.jwt_required()
def update_site(site_name):
# Check if request is JSON, and respects the JSON schema
if not flask.request.is_json:
flask.abort(http.HTTPStatus.BAD_REQUEST)
incoming_request = flask.request.get_json()
jsonschema.validate(incoming_request, schema.SITE_CREATION_MODIFICATION_SCHEMA)
# Check if user allowed to modify a site.
current_user = flask_jwt.current_identity
requesting_user_role = user_models.UserRole[current_user['role']]
if requesting_user_role != user_models.UserRole.admin:
flask.abort(http.HTTPStatus.UNAUTHORIZED)
# Modifies a site from the JSON.
existing_site = models.Site.query.filter_by(name=site_name).first_or_404()
updated_site = models.Site.from_json(incoming_request)
existing_site.base_url = updated_site.base_url
db.session.commit()
site_response = add_self_link(existing_site.to_json())
return flask.jsonify(site_response), http.HTTPStatus.OK
@rookeries_app.route('/api/sites/<site_name>', methods=['GET'])
def get_site(site_name):
site = models.Site.query.filter_by(name=site_name).first_or_404()
site_response = add_self_link(site.to_json())
return flask.jsonify(site_response)
@rookeries_app.route('/api/sites/<site_name>', methods=['DELETE'])
@flask_jwt.jwt_required()
def delete_site(site_name):
site = models.Site.query.filter_by(name=site_name).first_or_404()
current_user = flask_jwt.current_identity
requesting_user_role = user_models.UserRole[current_user['role']]
if requesting_user_role != user_models.UserRole.admin:
flask.abort(http.HTTPStatus.UNAUTHORIZED)
db.session.delete(site)
db.session.commit()
return '', http.HTTPStatus.NO_CONTENT

View File

View File

@ -0,0 +1,29 @@
"""
Models for tokens.
:copyright: Copyright 2013-2017, Dorian Pula <dorian.pula@amber-penguin-software.ca>
:license: AGPL v3+
"""
from rookeries.database import db
# TODO: Migrate tag line, copyright notice, favicon and logo image, other attributes into tokens linked to the site.
class Token(db.Model):
__tablename__ = 'token'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String)
value = db.Column(db.String)
site_id = db.Column(db.Integer, db.ForeignKey('site.id'))
site = db.relationship('Site')
# TODO: Add in concept of a language/internationalization?
def to_json(self) -> dict:
return {
'site_id': self.site.name,
'name': self.name,
'value': self.base_url,
}

31
rookeries/tokens/views.py Normal file
View File

@ -0,0 +1,31 @@
"""
Views for managing tokens.
:copyright: Copyright 2013-2017, Dorian Pula <dorian.pula@amber-penguin-software.ca>
:license: AGPL v3+
"""
import logging
import flask
from rookeries.main import rookeries_app
from rookeries.tokens import models
logger = logging.getLogger(__name__)
# TODO: Add in functionality to create and update tokens.
def add_self_link(block_json):
site_name = block_json.pop('site_id')
self_link = flask.url_for(
'rookeries_app.get_token', site_name=site_name, token_name=block_json['name'], _external=True)
block_json['urls']['self'] = self_link
return block_json
@rookeries_app.route('/api/sites/<site_name>/tokens/<token_name>', methods=['GET'])
def get_token(site_name, token_name):
token = models.Token.query.filter_by(site=site_name, name=token_name).first_or_404()
return flask.jsonify(token.to_json())

View File

@ -49,7 +49,7 @@ def create_migration(ctx, message):
docker_container_id = inv.run('cat /etc/hostname', hide='out').stdout.strip() docker_container_id = inv.run('cat /etc/hostname', hide='out').stdout.strip()
print('\nRetrieve the generated migration files by running: \n') print('\nRetrieve the generated migration files by running: \n')
path_to_migration = 'api/rookeries/migrations' path_to_migration = 'rookeries/migrations'
for migration in migration_files: for migration in migration_files:
print(f'docker cp {docker_container_id}:{migration.path} {path_to_migration}') print(f'docker cp {docker_container_id}:{migration.path} {path_to_migration}')

View File

@ -1,11 +1,27 @@
Feature: Site Access Feature: Site Access
The site GET endpoint allows user to access a site, its layout and landing page. The site GET endpoint allows user to access a site, its layout and landing page.
# TODO: Migrate to concept of a site "owner" vs "user"
# TODO: Add in checks to find menu and landing pages links for a site.
Scenario: Any user can get an existing site Scenario: Any user can get an existing site
Given I am an unauthenticated user Given I am a visitor
When I get an existing site And I get a published site
Then I get a valid site Then I get a valid site
And I get a valid site menu # And I get links to a menu
# And I get links to a landing page
# TODO: Create more test cases. Scenario: Any user can not get a site that does not exist
Given I am a visitor
And I get a site that does not exist
Then I get a site can not be found message
Scenario: An admin can get an existing site
Given I am an admin user
And I get a published site
Then I get a valid site
Scenario: An editor can get an existing site
Given I am an editor user
And I get a published site
Then I get a valid site

View File

@ -1,14 +1,28 @@
Feature: Site Creation Feature: Site Creation
The site endpoints allows an app admin user to create, modify and delete sites. The site endpoints allows a site admin to create sites.
Scenario: User cannot create a site using a non-json response
Given I am an admin user
And I try to create a site with a non-json request
Then I get a bad request response
Scenario: User cannot create a user using an empty response
Given I am an admin user
And I try to create a site with an empty request
Then I get a bad request response
Scenario: User cannot create a user twice using the same site name
Given I am an admin user
And I try to create a site
And I try to create a site with the same site name
Then I get a conflict response about an existing site
Scenario: Admin user can create a new site Scenario: Admin user can create a new site
Given I am an admin user Given I am an admin user
When I create a site And I try to create a site
Then I get a valid site Then I get a valid new site
And I get a valid site menu
Scenario: Editor user can not create a new site Scenario: Editor user can not create a new site
Given I am an editor user Given I am an editor user
When I create a site And I try to create a site
Then I get an unauthorized response Then I get an unauthorized response

View File

@ -1,16 +1,26 @@
Feature: Site Deletion Feature: Site Deletion
The site endpoints allows an app admin user to create, modify and delete sites. The site endpoints allows a site admin to delete sites.
Scenario: Admin user can delete a site Scenario: Admin user can delete a site
Given I am an admin user Given I am an admin user
When I get an existing site And I get a published site
And I delete the site And I try to delete the site
Then I get an error that the site is missing Then I get a response indicating the deletion was successful
And I get an error that the site menu is missing
Scenario: Admin user can not delete a site that does not exist
Given I am an admin user
And I get a site that does not exist
Then I get a site can not be found message
Scenario: Admin user can not delete a user twice
Given I am an admin user
And I get a published site
And I try to delete the site
And I try to delete the deleted site again
Then I get a site can not be found message
Scenario: Editor user can not delete a site Scenario: Editor user can not delete a site
Given I am an editor user Given I am an editor user
When I get an existing site And I get a published site
And I delete the site And I try to delete the site
Then I get an unauthorized response Then I get an unauthorized response

View File

@ -1,27 +1,31 @@
Feature: Site Modification Feature: Site Modification
The site endpoints allows an app admin user to create, modify and delete sites. The site endpoints allows an site admin user to modify sites.
Scenario: User cannot modify a site using a non-json request
Given I am an admin user
And I get a published site
And I try to modify the site with a non-json request
Then I get a bad request response
Scenario: User cannot modify a site using an empty request
Given I am an admin user
And I get a published site
And I try to modify the site with an empty request
Then I get a bad request response
Scenario: User cannot modify a non-existent site
Given I am an admin user
And I try to modify a site that does not exist
Then I get a site can not be found message
Scenario: Admin user can modify a site Scenario: Admin user can modify a site
Given I am an admin user Given I am an admin user
When I get an existing site And I get a published site
And I modify the site And I try to modify the site
Then my updates are reflected in the site Then my updates are reflected in the site
And I get a valid site menu
Scenario: Admin user can modify a site's menu
Given I am an admin user
When I get an existing site menu
And I modify the site menu
Then my updates are reflected in the site menu
Scenario: Editor user can not modify a site Scenario: Editor user can not modify a site
Given I am an editor user Given I am an editor user
When I get an existing site And I get a published site
And I modify the site And I try to modify the site
Then I get an unauthorized response
Scenario: Editor user can not modify a site's menu
Given I am an editor user
When I get an existing site menu
And I modify the site menu
Then I get an unauthorized response Then I get an unauthorized response

View File

@ -5,6 +5,10 @@ Functional tests for the managing sites.
:license: AGPL v3+ :license: AGPL v3+
""" """
import collections
import http
from unittest import mock
import pytest_bdd as bdd import pytest_bdd as bdd
import requests import requests
from pytest_bdd import parsers from pytest_bdd import parsers
@ -12,22 +16,288 @@ from pytest_bdd import parsers
from tests import utils from tests import utils
# bdd.scenarios('site_creation.feature') bdd.scenarios('site_creation.feature')
# bdd.scenarios('site_access.feature') bdd.scenarios('site_access.feature')
# bdd.scenarios('site_modification.feature') bdd.scenarios('site_modification.feature')
# bdd.scenarios('site_deletion.feature') bdd.scenarios('site_deletion.feature')
ResponseSiteBundle = collections.namedtuple('ResponseSiteBundle', ['response', 'site'])
@bdd.given(parsers.parse('I am an {user_role} user')) @bdd.given(parsers.parse('I am an {user_role} user'))
@bdd.given(parsers.parse('I am a {user_role} user')) @bdd.given(parsers.parse('I am a {user_role} user'))
def requester(user_role, api_base_uri, db_engine): def auth_token_headers(user_role, api_base_uri, db_engine):
user = utils.generate_test_user(role=user_role) user = utils.generate_test_user(role=user_role)
utils.save_test_user_in_db(db_engine, user) utils.save_test_user_in_db(db_engine, user)
return requests.post( token = requests.post(
url=f'{api_base_uri}/auth', url=f'{api_base_uri}/auth',
json={ json={
'username': user.username, 'username': user.username,
'password': user.password, 'password': user.password,
} }
).json()['access_token'] ).json()['access_token']
return {'Authorization': f'JWT {token}'}
@bdd.given(parsers.parse('I am a visitor'), target_fixture='auth_token_headers')
def no_auth_token_headers():
return {}
@bdd.given(parsers.parse('I try to create a site'), target_fixture='site_response')
def create_site_response(auth_token_headers, api_base_uri):
test_site = utils.generate_test_site()
site_creation_request = {
'name': test_site.site_name,
'base_url': test_site.base_url,
}
response = requests.post(
url=f'{api_base_uri}/api/sites/',
json=site_creation_request,
headers=auth_token_headers,
)
return ResponseSiteBundle(response, test_site)
@bdd.given(parsers.parse('I try to create a site with the same site name'), target_fixture='site_response')
def recreate_existing_site_response(site_response, auth_token_headers, api_base_uri):
test_site = site_response.site
site_creation_request = {
'name': test_site.site_name,
'base_url': test_site.base_url,
}
response = requests.post(
url=f'{api_base_uri}/api/sites/',
json=site_creation_request,
headers=auth_token_headers,
)
return ResponseSiteBundle(response, test_site)
@bdd.given(parsers.parse('I try to create a site with a non-json request'), target_fixture='site_response')
def invalid_non_json_create_site_response(auth_token_headers, api_base_uri):
return requests.post(
url=f'{api_base_uri}/api/sites/',
data='',
headers=auth_token_headers,
)
@bdd.given(parsers.parse('I try to create a site with an empty request'), target_fixture='site_response')
def invalid_empty_create_site_response(auth_token_headers, api_base_uri):
return requests.post(
url=f'{api_base_uri}/api/sites/',
json={},
headers=auth_token_headers,
)
@bdd.given(parsers.parse('I try to modify the site with a non-json request'), target_fixture='site_response')
def invalid_non_json_modify_site_response(site_response, auth_token_headers, api_base_uri):
test_site = site_response.site
return requests.put(
url=f'{api_base_uri}/api/sites/{test_site.site_name}',
data='',
headers=auth_token_headers,
)
@bdd.given(parsers.parse('I try to modify the site with an empty request'), target_fixture='site_response')
def invalid_empty_modify_site_response(site_response, auth_token_headers, api_base_uri):
test_site = site_response.site
return requests.put(
url=f'{api_base_uri}/api/sites/{test_site.site_name}',
json={},
headers=auth_token_headers,
)
@bdd.given(parsers.parse('I get a {state} site'), target_fixture='site_response')
def get_site_request(state, auth_token_headers, api_base_uri, db_engine):
# TODO: Generate a test site by its state
test_site = utils.generate_test_site()
utils.save_test_site_in_db(db_engine, test_site)
response = requests.get(
url=f'{api_base_uri}/api/sites/{test_site.site_name}',
headers=auth_token_headers,
)
return ResponseSiteBundle(site=test_site, response=response)
@bdd.given(parsers.parse('I get a site that does not exist'), target_fixture='site_response')
def get_non_existent_site_request(auth_token_headers, api_base_uri):
return requests.get(
url=f'{api_base_uri}/api/sites/this-does-not-exist',
headers=auth_token_headers,
)
@bdd.then(parsers.parse('I get a valid site'))
def assert_site_response(site_response, api_base_uri):
test_site = site_response.site
actual_response = utils.extract_response(site_response)
assert actual_response.status_code == http.HTTPStatus.OK
expected_json = {
'name': test_site.site_name,
'base_url': test_site.base_url,
'urls': mock.ANY,
}
assert expected_json == actual_response.json()
assert f'{api_base_uri}/api/sites/{test_site.site_name}' == actual_response.json()['urls']['self']
@bdd.then(parsers.parse('I get a valid new site'))
def assert_new_site_response(site_response, api_base_uri):
test_site = site_response.site
actual_response = utils.extract_response(site_response)
assert actual_response.status_code == http.HTTPStatus.CREATED
expected_json = {
'name': test_site.site_name,
'base_url': test_site.base_url,
'urls': mock.ANY,
}
assert expected_json == actual_response.json()
assert f'{api_base_uri}/api/sites/{test_site.site_name}' == actual_response.json()['urls']['self']
@bdd.given(parsers.parse('I try to delete the site'), target_fixture='site_response')
def delete_site_response(site_response, auth_token_headers, api_base_uri):
test_site = site_response.site
response = requests.delete(
url=f'{api_base_uri}/api/sites/{test_site.site_name}',
headers=auth_token_headers,
)
return ResponseSiteBundle(response, test_site)
@bdd.given(parsers.parse('I try to delete the deleted site again'), target_fixture='site_response')
def delete_deleted_site_response(site_response, auth_token_headers, api_base_uri):
return delete_site_response(site_response, auth_token_headers, api_base_uri)
@bdd.given(parsers.parse('I try to modify the site'), target_fixture='site_response')
def modify_site_response(site_response, auth_token_headers, api_base_uri):
test_site = site_response.site
modified_base_url = f'{test_site.base_url}/new_'
modified_site = utils.generate_test_site(test_site.site_name, modified_base_url)
site_modification_request = {
'name': test_site.site_name,
'base_url': modified_base_url,
}
response = requests.put(
url=f'{api_base_uri}/api/sites/{test_site.site_name}',
json=site_modification_request,
headers=auth_token_headers,
)
return ResponseSiteBundle(response, modified_site)
@bdd.given(parsers.parse('I try to modify a site that does not exist'), target_fixture='site_response')
def modify_non_existent_site_request(auth_token_headers, api_base_uri):
test_site = utils.generate_test_site()
site_modification_request = {
'name': 'this-does-not-exist',
'base_url': test_site.base_url,
}
return requests.put(
url=f'{api_base_uri}/api/sites/this-does-not-exist',
json=site_modification_request,
headers=auth_token_headers,
)
@bdd.then(parsers.parse('my updates are reflected in the site'))
def assert_updated_site_response(site_response, api_base_uri):
test_site = site_response.site
actual_response = utils.extract_response(site_response)
assert actual_response.status_code == http.HTTPStatus.OK
expected_json = {
'name': test_site.site_name,
'base_url': test_site.base_url,
'urls': mock.ANY,
}
assert expected_json == actual_response.json()
assert f'{api_base_uri}/api/sites/{test_site.site_name}' == actual_response.json()['urls']['self']
@bdd.then(parsers.parse('I get a site can not be found message'))
def assert_resource_not_found_response(site_response):
actual_response = utils.extract_response(site_response)
expected_response_json = {
'error': {
'status_code': http.HTTPStatus.NOT_FOUND.value,
'message': 'Resource not found.',
'resource': actual_response.request.url,
},
}
assert actual_response.status_code == http.HTTPStatus.NOT_FOUND
assert actual_response.json() == expected_response_json
@bdd.then(parsers.parse('I get an unauthorized response'))
def assert_unauthorized_response(site_response):
actual_response = utils.extract_response(site_response)
expected_response_json = {
'error': {
'status_code': http.HTTPStatus.UNAUTHORIZED.value,
'message': 'Not authorized to access this resource.',
'resource': actual_response.request.url,
}
}
assert actual_response.status_code == http.HTTPStatus.UNAUTHORIZED
assert actual_response.json() == expected_response_json
@bdd.then(parsers.parse('I get a conflict response about an existing site'))
def assert_conflicting_resource_found_response(site_response):
actual_response = utils.extract_response(site_response)
expected_response_json = {
'error': {
'status_code': http.HTTPStatus.CONFLICT.value,
'message': 'Existing resource already found. PUT to the resource to update it.',
'resource': actual_response.request.url,
},
}
assert actual_response.status_code == http.HTTPStatus.CONFLICT
assert actual_response.json() == expected_response_json
@bdd.then(parsers.parse('I get a bad request response'))
def assert_bad_request_response(site_response):
actual_response = utils.extract_response(site_response)
expected_response_json = {
'error': {
'status_code': http.HTTPStatus.BAD_REQUEST.value,
'message': mock.ANY,
}
}
assert actual_response.json() == expected_response_json
assert actual_response.status_code == http.HTTPStatus.BAD_REQUEST
@bdd.then(parsers.parse('I get a response indicating the deletion was successful'))
def assert_resource_deleted_response(site_response):
actual_response = utils.extract_response(site_response)
assert actual_response.status_code == http.HTTPStatus.NO_CONTENT
assert not actual_response.content
assert len(actual_response.content) == 0

View File

@ -30,12 +30,6 @@ TokenUserBundle = collections.namedtuple('TokenUserBundle', ['user', 'token'])
ResponseUserBundle = collections.namedtuple('ResponseUserBundle', ['response', 'user']) ResponseUserBundle = collections.namedtuple('ResponseUserBundle', ['response', 'user'])
def extract_response(response):
if isinstance(response, requests.Response):
return response
return response.response
@bdd.given(parsers.parse('I am an {user_role} user')) @bdd.given(parsers.parse('I am an {user_role} user'))
@bdd.given(parsers.parse('I am a {user_role} user')) @bdd.given(parsers.parse('I am a {user_role} user'))
def requester(user_role, api_base_uri, db_engine): def requester(user_role, api_base_uri, db_engine):
@ -309,7 +303,7 @@ def assert_create_user_response(user_role, user_response, api_base_uri):
} }
} }
actual_response = extract_response(user_response) actual_response = utils.extract_response(user_response)
assert actual_response.status_code == http.HTTPStatus.CREATED assert actual_response.status_code == http.HTTPStatus.CREATED
assert actual_response.json() == expected_user_creation_response assert actual_response.json() == expected_user_creation_response
@ -330,7 +324,7 @@ def assert_my_user_profile(requester, user_response, api_base_uri):
} }
} }
actual_response = extract_response(user_response) actual_response = utils.extract_response(user_response)
assert actual_response.status_code == http.HTTPStatus.OK assert actual_response.status_code == http.HTTPStatus.OK
assert actual_response.json() == expected_user_creation_response assert actual_response.json() == expected_user_creation_response
assert MODIFIED_PREFIX not in actual_response.json()['profile']['fullName'] assert MODIFIED_PREFIX not in actual_response.json()['profile']['fullName']
@ -354,7 +348,7 @@ def assert_user_profile(user_role, user_response, api_base_uri):
} }
} }
actual_response = extract_response(user_response) actual_response = utils.extract_response(user_response)
assert actual_response.status_code == http.HTTPStatus.OK assert actual_response.status_code == http.HTTPStatus.OK
assert actual_response.json() == expected_user_creation_response assert actual_response.json() == expected_user_creation_response
@ -375,7 +369,7 @@ def assert_my_modified_user_profile(requester, user_response, api_base_uri):
} }
} }
actual_response = extract_response(user_response) actual_response = utils.extract_response(user_response)
assert actual_response.status_code == http.HTTPStatus.CREATED assert actual_response.status_code == http.HTTPStatus.CREATED
assert actual_response.json() == expected_user_creation_response assert actual_response.json() == expected_user_creation_response
@ -397,14 +391,14 @@ def assert_modified_user_profile(user_role, user_response, api_base_uri):
} }
} }
actual_response = extract_response(user_response) actual_response = utils.extract_response(user_response)
assert actual_response.status_code == http.HTTPStatus.CREATED assert actual_response.status_code == http.HTTPStatus.CREATED
assert actual_response.json() == expected_user_response assert actual_response.json() == expected_user_response
@bdd.then(parsers.parse('I get an unauthorized response')) @bdd.then(parsers.parse('I get an unauthorized response'))
def assert_unauthorized_response(user_response): def assert_unauthorized_response(user_response):
actual_response = extract_response(user_response) actual_response = utils.extract_response(user_response)
expected_response_json = { expected_response_json = {
'error': { 'error': {
'status_code': http.HTTPStatus.UNAUTHORIZED.value, 'status_code': http.HTTPStatus.UNAUTHORIZED.value,
@ -419,7 +413,7 @@ def assert_unauthorized_response(user_response):
@bdd.then(parsers.parse('I get a forbidden response')) @bdd.then(parsers.parse('I get a forbidden response'))
def assert_forbidden_response(user_response): def assert_forbidden_response(user_response):
actual_response = extract_response(user_response) actual_response = utils.extract_response(user_response)
expected_response_json = { expected_response_json = {
'error': { 'error': {
'status_code': http.HTTPStatus.FORBIDDEN.value, 'status_code': http.HTTPStatus.FORBIDDEN.value,
@ -434,7 +428,7 @@ def assert_forbidden_response(user_response):
@bdd.then(parsers.parse('I get a user can not be found message')) @bdd.then(parsers.parse('I get a user can not be found message'))
def assert_resource_not_found_response(user_response): def assert_resource_not_found_response(user_response):
actual_response = extract_response(user_response) actual_response = utils.extract_response(user_response)
expected_response_json = { expected_response_json = {
'error': { 'error': {
'status_code': http.HTTPStatus.NOT_FOUND.value, 'status_code': http.HTTPStatus.NOT_FOUND.value,
@ -449,7 +443,7 @@ def assert_resource_not_found_response(user_response):
@bdd.then(parsers.parse('I get a conflict response about existing user')) @bdd.then(parsers.parse('I get a conflict response about existing user'))
def assert_conflicting_resource_found_response(user_response): def assert_conflicting_resource_found_response(user_response):
actual_response = extract_response(user_response) actual_response = utils.extract_response(user_response)
expected_response_json = { expected_response_json = {
'error': { 'error': {
'status_code': http.HTTPStatus.CONFLICT.value, 'status_code': http.HTTPStatus.CONFLICT.value,
@ -464,7 +458,7 @@ def assert_conflicting_resource_found_response(user_response):
@bdd.then(parsers.parse('I get a bad request response')) @bdd.then(parsers.parse('I get a bad request response'))
def assert_bad_request_response(user_response): def assert_bad_request_response(user_response):
actual_response = extract_response(user_response) actual_response = utils.extract_response(user_response)
expected_response_json = { expected_response_json = {
'error': { 'error': {
'status_code': http.HTTPStatus.BAD_REQUEST.value, 'status_code': http.HTTPStatus.BAD_REQUEST.value,
@ -478,7 +472,7 @@ def assert_bad_request_response(user_response):
@bdd.then(parsers.parse('I get a response indicating the deletion was successful')) @bdd.then(parsers.parse('I get a response indicating the deletion was successful'))
def assert_resource_deleted_response(user_response): def assert_resource_deleted_response(user_response):
actual_response = extract_response(user_response) actual_response = utils.extract_response(user_response)
assert actual_response.status_code == http.HTTPStatus.NO_CONTENT assert actual_response.status_code == http.HTTPStatus.NO_CONTENT
assert not actual_response.content assert not actual_response.content
assert len(actual_response.content) == 0 assert len(actual_response.content) == 0

View File

@ -8,14 +8,19 @@ Utilities for testing.
import collections import collections
import random import random
import string import string
import uuid
import requests
from sqlalchemy import orm from sqlalchemy import orm
from werkzeug import security from werkzeug import security
from rookeries.users import models from rookeries.sites import models as site_models
from rookeries.users import models as user_models
RANDOM_SUFFIX_CHARACTERS = list(string.ascii_lowercase + string.digits)
TestUserInfo = collections.namedtuple('TestUserInfo', ['username', 'password', 'role', 'email']) TestUserInfo = collections.namedtuple('TestUserInfo', ['username', 'password', 'role', 'email'])
TestSiteInfo = collections.namedtuple('TestSiteInfo', ['site_name', 'base_url', 'menu', 'landing_page'])
def generate_test_password(): def generate_test_password():
@ -26,7 +31,11 @@ def hash_password(password):
return security.generate_password_hash(password) return security.generate_password_hash(password)
def generate_test_user(user_prefix='', role=models.UserRole.subscriber.name) -> TestUserInfo: def generate_random_suffix():
return ''.join(random.choices(RANDOM_SUFFIX_CHARACTERS, k=10))
def generate_test_user(user_prefix='', role=user_models.UserRole.subscriber.name) -> TestUserInfo:
""" """
Creates a new test user. Creates a new test user.
@ -35,13 +44,13 @@ def generate_test_user(user_prefix='', role=models.UserRole.subscriber.name) ->
:return: A tuple with information about the test user. :return: A tuple with information about the test user.
""" """
username = f'{user_prefix}-{uuid.uuid4()}' if user_prefix else f'test-{role}-{uuid.uuid4()}' username = f'{user_prefix}-{generate_random_suffix()}' if user_prefix else f'test-{role}-{generate_random_suffix()}'
email = f'{username}@test.rookeries.org' email = f'{username}@test.rookeries.org'
password = generate_test_password() password = generate_test_password()
return TestUserInfo(username=username, password=password, role=role, email=email) return TestUserInfo(username=username, password=password, role=role, email=email)
def save_test_user_in_db(db_engine, test_user): def save_test_user_in_db(db_engine, test_user: TestUserInfo):
""" """
Saves a given test user into the database. Saves a given test user into the database.
@ -51,11 +60,11 @@ def save_test_user_in_db(db_engine, test_user):
session = orm.sessionmaker(bind=db_engine, autocommit=True)() session = orm.sessionmaker(bind=db_engine, autocommit=True)()
session.begin() session.begin()
user = models.User(test_user.username) user = user_models.User(test_user.username)
user.password = hash_password(test_user.password) user.password = hash_password(test_user.password)
user.role = test_user.role user.role = test_user.role
user_profile = models.UserProfile() user_profile = user_models.UserProfile()
user_profile.full_name = f'Test {test_user.role.capitalize()}' user_profile.full_name = f'Test {test_user.role.capitalize()}'
user_profile.email = test_user.email user_profile.email = test_user.email
user.profile = user_profile user.profile = user_profile
@ -63,3 +72,42 @@ def save_test_user_in_db(db_engine, test_user):
session.add(user) session.add(user)
session.commit() session.commit()
session.close() session.close()
def generate_test_site(site_name='', base_url='') -> TestSiteInfo:
"""
Creates a new test site.
:param site_name: The name of the test site.
:param base_url: The base url for the site.
:return: A tuple with information about the test site.
"""
test_site_name = site_name if site_name else f'test-site-{generate_random_suffix()}'
site_base_url = f'{test_site_name}.rookeries.org' if not base_url else base_url
menu = f'{site_base_url}/blocks/{generate_random_suffix()}'
landing_page = f'{site_base_url}/pages/{generate_random_suffix()}'
return TestSiteInfo(site_name=test_site_name, base_url=site_base_url, menu=menu, landing_page=landing_page)
def save_test_site_in_db(db_engine, test_site: TestSiteInfo):
"""
Saves a given test site into the database.
:param db_engine: The DB engine to spawn a session from.
:param test_site: The test site to save.
"""
session = orm.sessionmaker(bind=db_engine, autocommit=True)()
session.begin()
site = site_models.Site(test_site.site_name, test_site.base_url)
session.add(site)
session.commit()
session.close()
def extract_response(response):
if isinstance(response, requests.Response):
return response
return response.response