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:
parent
4d689b2f3a
commit
499724732f
2
Makefile
2
Makefile
|
@ -14,7 +14,7 @@ test: build test-api test-ui
|
|||
# Runs API tests
|
||||
test-api: stop build
|
||||
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.init
|
||||
docker-compose run rookeries inv db.upgrade
|
||||
|
|
|
@ -16,7 +16,6 @@ services:
|
|||
testing:
|
||||
image: selenium/standalone-firefox-debug
|
||||
ports:
|
||||
- "4444:4444"
|
||||
- "5900:5900"
|
||||
|
||||
db:
|
||||
|
|
|
@ -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,
|
||||
}
|
|
@ -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)
|
|
@ -18,6 +18,8 @@ class DefaultConfig(object):
|
|||
|
||||
# Database connection string.
|
||||
SQLALCHEMY_DATABASE_URI = 'postgresql+psycopg2://admin:password@localhost:5432/rookeries'
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
SQLALCHEMY_ECHO = False
|
||||
|
||||
# Security config
|
||||
# Key used by Flask for session secrets.
|
||||
|
|
|
@ -99,7 +99,7 @@ def error_message_from_exception(error: Exception):
|
|||
error_message = {
|
||||
'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
|
||||
|
|
|
@ -16,11 +16,13 @@ rookeries_app = flask.Blueprint('rookeries_app', __name__)
|
|||
def register_views():
|
||||
"""Import the various views in the app."""
|
||||
rookeries_views = [
|
||||
'rookeries.views',
|
||||
'rookeries.pages.views',
|
||||
'rookeries.security',
|
||||
'rookeries.blocks.views',
|
||||
'rookeries.sites.views',
|
||||
'rookeries.security',
|
||||
'rookeries.pages.views',
|
||||
'rookeries.tokens.views',
|
||||
'rookeries.users.views',
|
||||
'rookeries.views',
|
||||
]
|
||||
for views_modules in rookeries_views:
|
||||
importlib.import_module(views_modules)
|
||||
|
|
|
@ -5,9 +5,7 @@ Models for sites.
|
|||
:license: AGPL v3+
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy_utils.types import url as sql_url, uuid as sql_uuid
|
||||
from sqlalchemy_utils.types import url as sql_url
|
||||
|
||||
from rookeries.database import db
|
||||
|
||||
|
@ -16,59 +14,32 @@ class Site(db.Model):
|
|||
__tablename__ = 'site'
|
||||
|
||||
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))
|
||||
url = db.Column(sql_url.URLType)
|
||||
name = db.Column(db.String(64), unique=True, nullable=False)
|
||||
base_url = db.Column(sql_url.URLType)
|
||||
|
||||
# TODO: Add in tag line, copyright notice, favicon and logo image, other attributes in here.
|
||||
config = db.Column(db.JSON)
|
||||
menu = db.relationship('SiteMenu', order_by='SiteMenu.orderinal', back_populates='site')
|
||||
# TODO: Add in relations for blocks and a landing page as well...
|
||||
# blocks = db.relationship('Block', 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.url = url
|
||||
self.config = config or {}
|
||||
self.api_key = api_key or uuid.uuid4()
|
||||
self.base_url = url
|
||||
|
||||
def __repr__(self) -> str:
|
||||
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:
|
||||
# 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 {
|
||||
'api_key': self.api_key,
|
||||
'name': self.name,
|
||||
'url': self.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,
|
||||
'base_url': self.base_url,
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
}
|
|
@ -5,33 +5,107 @@ Views for managing sites.
|
|||
:license: AGPL v3+
|
||||
"""
|
||||
|
||||
import http
|
||||
import logging
|
||||
|
||||
import flask
|
||||
import jsonschema
|
||||
|
||||
from rookeries import errors
|
||||
from rookeries.database import db
|
||||
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__)
|
||||
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 app_config():
|
||||
|
||||
# Get API Key from header
|
||||
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 flask.jsonify(site.to_json())
|
||||
def add_self_link(site_json):
|
||||
# TODO: Consider creating a better way to resolve IDs in links to resources, in a generic manner.
|
||||
site_json['urls'] = {
|
||||
'self': flask.url_for('rookeries_app.get_site', site_name=site_json['name'], _external=True),
|
||||
}
|
||||
return site_json
|
||||
|
||||
|
||||
@rookeries_app.route('/api/site/menu', methods=['GET'])
|
||||
def menu_config():
|
||||
@rookeries_app.route('/api/sites/', methods=['POST'])
|
||||
@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
|
||||
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()
|
||||
nav_menu = {'menu': [menu_item.to_json() for menu_item in site.menu]}
|
||||
return flask.jsonify(nav_menu)
|
||||
incoming_request = flask.request.get_json()
|
||||
jsonschema.validate(incoming_request, schema.SITE_CREATION_MODIFICATION_SCHEMA)
|
||||
|
||||
# Check if user allowed to create 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)
|
||||
|
||||
# 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
|
||||
|
|
|
@ -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,
|
||||
}
|
|
@ -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())
|
|
@ -49,7 +49,7 @@ def create_migration(ctx, message):
|
|||
docker_container_id = inv.run('cat /etc/hostname', hide='out').stdout.strip()
|
||||
|
||||
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:
|
||||
print(f'docker cp {docker_container_id}:{migration.path} {path_to_migration}')
|
||||
|
|
|
@ -1,11 +1,27 @@
|
|||
Feature: Site Access
|
||||
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
|
||||
Given I am an unauthenticated user
|
||||
When I get an existing site
|
||||
Given I am a visitor
|
||||
And I get a published 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
|
||||
|
|
|
@ -1,14 +1,28 @@
|
|||
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
|
||||
Given I am an admin user
|
||||
When I create a site
|
||||
Then I get a valid site
|
||||
And I get a valid site menu
|
||||
And I try to create a site
|
||||
Then I get a valid new site
|
||||
|
||||
Scenario: Editor user can not create a new site
|
||||
Given I am an editor user
|
||||
When I create a site
|
||||
And I try to create a site
|
||||
Then I get an unauthorized response
|
||||
|
|
|
@ -1,16 +1,26 @@
|
|||
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
|
||||
Given I am an admin user
|
||||
When I get an existing site
|
||||
And I delete the site
|
||||
Then I get an error that the site is missing
|
||||
And I get an error that the site menu is missing
|
||||
And I get a published site
|
||||
And I try to delete the site
|
||||
Then I get a response indicating the deletion was successful
|
||||
|
||||
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
|
||||
Given I am an editor user
|
||||
When I get an existing site
|
||||
And I delete the site
|
||||
And I get a published site
|
||||
And I try to delete the site
|
||||
Then I get an unauthorized response
|
||||
|
|
|
@ -1,27 +1,31 @@
|
|||
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
|
||||
Given I am an admin user
|
||||
When I get an existing site
|
||||
And I modify the site
|
||||
And I get a published site
|
||||
And I try to modify 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
|
||||
Given I am an editor user
|
||||
When I get an existing site
|
||||
And I 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
|
||||
And I get a published site
|
||||
And I try to modify the site
|
||||
Then I get an unauthorized response
|
||||
|
|
|
@ -5,6 +5,10 @@ Functional tests for the managing sites.
|
|||
:license: AGPL v3+
|
||||
"""
|
||||
|
||||
import collections
|
||||
import http
|
||||
from unittest import mock
|
||||
|
||||
import pytest_bdd as bdd
|
||||
import requests
|
||||
from pytest_bdd import parsers
|
||||
|
@ -12,22 +16,288 @@ from pytest_bdd import parsers
|
|||
from tests import utils
|
||||
|
||||
|
||||
# bdd.scenarios('site_creation.feature')
|
||||
# bdd.scenarios('site_access.feature')
|
||||
# bdd.scenarios('site_modification.feature')
|
||||
# bdd.scenarios('site_deletion.feature')
|
||||
bdd.scenarios('site_creation.feature')
|
||||
bdd.scenarios('site_access.feature')
|
||||
bdd.scenarios('site_modification.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 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)
|
||||
utils.save_test_user_in_db(db_engine, user)
|
||||
return requests.post(
|
||||
token = requests.post(
|
||||
url=f'{api_base_uri}/auth',
|
||||
json={
|
||||
'username': user.username,
|
||||
'password': user.password,
|
||||
}
|
||||
).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
|
||||
|
|
|
@ -30,12 +30,6 @@ TokenUserBundle = collections.namedtuple('TokenUserBundle', ['user', 'token'])
|
|||
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 a {user_role} user'))
|
||||
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.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.json() == expected_user_creation_response
|
||||
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.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.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.json() == expected_user_response
|
||||
|
||||
|
||||
@bdd.then(parsers.parse('I get an unauthorized response'))
|
||||
def assert_unauthorized_response(user_response):
|
||||
actual_response = extract_response(user_response)
|
||||
actual_response = utils.extract_response(user_response)
|
||||
expected_response_json = {
|
||||
'error': {
|
||||
'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'))
|
||||
def assert_forbidden_response(user_response):
|
||||
actual_response = extract_response(user_response)
|
||||
actual_response = utils.extract_response(user_response)
|
||||
expected_response_json = {
|
||||
'error': {
|
||||
'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'))
|
||||
def assert_resource_not_found_response(user_response):
|
||||
actual_response = extract_response(user_response)
|
||||
actual_response = utils.extract_response(user_response)
|
||||
expected_response_json = {
|
||||
'error': {
|
||||
'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'))
|
||||
def assert_conflicting_resource_found_response(user_response):
|
||||
actual_response = extract_response(user_response)
|
||||
actual_response = utils.extract_response(user_response)
|
||||
expected_response_json = {
|
||||
'error': {
|
||||
'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'))
|
||||
def assert_bad_request_response(user_response):
|
||||
actual_response = extract_response(user_response)
|
||||
actual_response = utils.extract_response(user_response)
|
||||
expected_response_json = {
|
||||
'error': {
|
||||
'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'))
|
||||
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 not actual_response.content
|
||||
assert len(actual_response.content) == 0
|
||||
|
|
|
@ -8,14 +8,19 @@ Utilities for testing.
|
|||
import collections
|
||||
import random
|
||||
import string
|
||||
import uuid
|
||||
|
||||
import requests
|
||||
from sqlalchemy import orm
|
||||
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'])
|
||||
TestSiteInfo = collections.namedtuple('TestSiteInfo', ['site_name', 'base_url', 'menu', 'landing_page'])
|
||||
|
||||
|
||||
def generate_test_password():
|
||||
|
@ -26,7 +31,11 @@ def hash_password(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.
|
||||
|
||||
|
@ -35,13 +44,13 @@ def generate_test_user(user_prefix='', role=models.UserRole.subscriber.name) ->
|
|||
: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'
|
||||
password = generate_test_password()
|
||||
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.
|
||||
|
||||
|
@ -51,11 +60,11 @@ def save_test_user_in_db(db_engine, test_user):
|
|||
|
||||
session = orm.sessionmaker(bind=db_engine, autocommit=True)()
|
||||
session.begin()
|
||||
user = models.User(test_user.username)
|
||||
user = user_models.User(test_user.username)
|
||||
user.password = hash_password(test_user.password)
|
||||
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.email = test_user.email
|
||||
user.profile = user_profile
|
||||
|
@ -63,3 +72,42 @@ def save_test_user_in_db(db_engine, test_user):
|
|||
session.add(user)
|
||||
session.commit()
|
||||
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
|
||||
|
|
Loading…
Reference in New Issue