From a4b0781604fd217341cc43eec47a95c725860ced Mon Sep 17 00:00:00 2001 From: Michael Merickel Date: Mon, 6 Jan 2020 22:57:34 -0600 Subject: sync basiclayout, installation, models with new structure --- docs/tutorials/wiki2/basiclayout.rst | 14 +-- docs/tutorials/wiki2/definingmodels.rst | 10 +- docs/tutorials/wiki2/installation.rst | 53 ++++----- docs/tutorials/wiki2/src/basiclayout/.gitignore | 1 + docs/tutorials/wiki2/src/basiclayout/testing.ini | 79 +++++++++++++ .../wiki2/src/basiclayout/tests/conftest.py | 125 +++++++++++++++++++++ .../wiki2/src/basiclayout/tests/test_functional.py | 13 +++ .../wiki2/src/basiclayout/tests/test_it.py | 66 ----------- .../wiki2/src/basiclayout/tests/test_views.py | 23 ++++ .../wiki2/src/basiclayout/tutorial/__init__.py | 2 +- .../src/basiclayout/tutorial/models/__init__.py | 22 ++-- .../src/basiclayout/tutorial/views/default.py | 7 +- docs/tutorials/wiki2/src/installation/.gitignore | 1 + docs/tutorials/wiki2/src/installation/testing.ini | 79 +++++++++++++ .../wiki2/src/installation/tests/conftest.py | 125 +++++++++++++++++++++ .../src/installation/tests/test_functional.py | 13 +++ .../wiki2/src/installation/tests/test_it.py | 66 ----------- .../wiki2/src/installation/tests/test_views.py | 23 ++++ .../wiki2/src/installation/tutorial/__init__.py | 2 +- .../src/installation/tutorial/models/__init__.py | 22 ++-- .../src/installation/tutorial/views/default.py | 7 +- docs/tutorials/wiki2/src/models/.gitignore | 1 + docs/tutorials/wiki2/src/models/setup.py | 2 +- docs/tutorials/wiki2/src/models/testing.ini | 79 +++++++++++++ docs/tutorials/wiki2/src/models/tests/conftest.py | 125 +++++++++++++++++++++ .../wiki2/src/models/tests/test_functional.py | 13 +++ docs/tutorials/wiki2/src/models/tests/test_it.py | 66 ----------- .../tutorials/wiki2/src/models/tests/test_views.py | 23 ++++ .../wiki2/src/models/tutorial/__init__.py | 2 +- .../wiki2/src/models/tutorial/models/__init__.py | 22 ++-- .../src/models/tutorial/scripts/initialize_db.py | 4 + .../wiki2/src/models/tutorial/views/default.py | 7 +- 32 files changed, 827 insertions(+), 270 deletions(-) create mode 100644 docs/tutorials/wiki2/src/basiclayout/testing.ini create mode 100644 docs/tutorials/wiki2/src/basiclayout/tests/conftest.py create mode 100644 docs/tutorials/wiki2/src/basiclayout/tests/test_functional.py delete mode 100644 docs/tutorials/wiki2/src/basiclayout/tests/test_it.py create mode 100644 docs/tutorials/wiki2/src/basiclayout/tests/test_views.py create mode 100644 docs/tutorials/wiki2/src/installation/testing.ini create mode 100644 docs/tutorials/wiki2/src/installation/tests/conftest.py create mode 100644 docs/tutorials/wiki2/src/installation/tests/test_functional.py delete mode 100644 docs/tutorials/wiki2/src/installation/tests/test_it.py create mode 100644 docs/tutorials/wiki2/src/installation/tests/test_views.py create mode 100644 docs/tutorials/wiki2/src/models/testing.ini create mode 100644 docs/tutorials/wiki2/src/models/tests/conftest.py create mode 100644 docs/tutorials/wiki2/src/models/tests/test_functional.py delete mode 100644 docs/tutorials/wiki2/src/models/tests/test_it.py create mode 100644 docs/tutorials/wiki2/src/models/tests/test_views.py (limited to 'docs/tutorials') diff --git a/docs/tutorials/wiki2/basiclayout.rst b/docs/tutorials/wiki2/basiclayout.rst index ae58d80a5..e8bc4c5a9 100644 --- a/docs/tutorials/wiki2/basiclayout.rst +++ b/docs/tutorials/wiki2/basiclayout.rst @@ -58,24 +58,24 @@ dictionary of settings parsed from the ``.ini`` file, which contains deployment-related values, such as ``pyramid.reload_templates``, ``sqlalchemy.url``, and so on. -Next include the package ``models`` using a dotted Python path. The exact -setup of the models will be covered later. +Next include :term:`Jinja2` templating bindings so that we can use renderers +with the ``.jinja2`` extension within our project. .. literalinclude:: src/basiclayout/tutorial/__init__.py :lines: 8 :lineno-match: :language: py -Next include :term:`Jinja2` templating bindings so that we can use renderers -with the ``.jinja2`` extension within our project. +Next include the ``routes`` module using a dotted Python path. This module will +be explained in the next section. .. literalinclude:: src/basiclayout/tutorial/__init__.py :lines: 9 :lineno-match: :language: py -Next include the ``routes`` module using a dotted Python path. This module will -be explained in the next section. +Next include the package ``models`` using a dotted Python path. The exact +setup of the models will be covered later. .. literalinclude:: src/basiclayout/tutorial/__init__.py :lines: 10 @@ -207,7 +207,7 @@ Without repeating ourselves, we will point out the differences between this view Content models with the ``models`` package ------------------------------------------ -In an SQLAlchemy-based application, a *model* object is an object composed by +In a SQLAlchemy-based application, a *model* object is an object composed by querying the SQL database. The ``models`` package is where the ``alchemy`` cookiecutter put the classes that implement our models. diff --git a/docs/tutorials/wiki2/definingmodels.rst b/docs/tutorials/wiki2/definingmodels.rst index 4b80e09ac..f84ca6588 100644 --- a/docs/tutorials/wiki2/definingmodels.rst +++ b/docs/tutorials/wiki2/definingmodels.rst @@ -32,8 +32,10 @@ parameter in the ``setup()`` function. Open ``tutorial/setup.py`` and edit it to look like the following: .. literalinclude:: src/models/setup.py + :lines: 11-30 :linenos: - :emphasize-lines: 11-24 + :lineno-match: + :emphasize-lines: 3 :language: python It is a good practice to sort packages alphabetically to make them easier to find. @@ -42,7 +44,9 @@ After adding ``bcrypt`` and sorting packages, we should have the above ``require .. note:: - We are using the ``bcrypt`` package from PyPI to hash our passwords securely. There are other one-way hash algorithms for passwords if ``bcrypt`` is an issue on your system. Just make sure that it's an algorithm approved for storing passwords versus a generic one-way hash. + We are using the ``bcrypt`` package from PyPI to hash our passwords securely. + There are other one-way hash algorithms for passwords if ``bcrypt`` is an issue on your system. + Just make sure that it's an algorithm approved for storing passwords versus a generic one-way hash. Running ``pip install -e .`` @@ -245,7 +249,7 @@ following: .. literalinclude:: src/models/tutorial/scripts/initialize_db.py :linenos: :language: python - :emphasize-lines: 11-24 + :emphasize-lines: 15-28 Only the highlighted lines need to be changed. diff --git a/docs/tutorials/wiki2/installation.rst b/docs/tutorials/wiki2/installation.rst index 55fca15a1..b144fc4e0 100644 --- a/docs/tutorials/wiki2/installation.rst +++ b/docs/tutorials/wiki2/installation.rst @@ -182,8 +182,8 @@ The console will show ``pip`` checking for packages and installing missing packa alembic-1.3.2 attrs-19.3.0 beautifulsoup4-4.8.2 coverage-5.0.1 \ hupper-1.9.1 importlib-metadata-1.3.0 more-itertools-8.0.2 packaging-19.2 \ plaster-1.0 plaster-pastedeploy-0.7 pluggy-0.13.1 py-1.8.1 \ - pyparsing-2.4.6 pyramid-1.10.4 pyramid-debugtoolbar-4.5.1 \ - pyramid-jinja2-2.8 pyramid-mako-1.1.0 pyramid-retry-2.1 pyramid-tm-2.3 \ + pyparsing-2.4.6 pyramid-1.10.4 pyramid-debugtoolbar-4.5.2 \ + pyramid-jinja2-2.8 pyramid-mako-1.1.0 pyramid-retry-2.1 pyramid-tm-2.4 \ pytest-5.3.2 pytest-cov-2.8.1 python-dateutil-2.8.1 python-editor-1.0.4 \ repoze.lru-0.7 six-1.13.0 soupsieve-1.9.5 transaction-3.0.0 \ translationstring-1.3 tutorial venusian-3.0.0 waitress-1.4.1 \ @@ -350,30 +350,33 @@ If successful, you will see output something like this: ======================== test session starts ======================== platform -- Python 3.7.3, pytest-5.3.2, py-1.8.1, pluggy-0.13.1 - rootdir: /tutorial, inifile: pytest.ini, testpaths: tutorial + rootdir: /tutorial, inifile: pytest.ini, testpaths: tutorial, tests plugins: cov-2.8.1 - collected 2 items - - tutorial/tests.py .. - - ------------------ coverage: platform Python 3.7.3 ------------------ - Name Stmts Miss Cover Missing - ----------------------------------------------------------------- - tutorial/__init__.py 8 6 25% 7-12 - tutorial/models/__init__.py 24 0 100% - tutorial/models/meta.py 5 0 100% - tutorial/models/mymodel.py 8 0 100% - tutorial/pshell.py 7 7 0% 1-13 - tutorial/routes.py 3 3 0% 1-3 - tutorial/scripts/__init__.py 0 0 100% - tutorial/scripts/initialize_db.py 22 22 0% 1-38 - tutorial/views/__init__.py 0 0 100% - tutorial/views/default.py 12 0 100% - tutorial/views/notfound.py 4 4 0% 1-7 - ----------------------------------------------------------------- - TOTAL 93 42 55% - - ===================== 2 passed in 0.64 seconds ====================== + collected 5 items + + tests/test_functional.py .. + tests/test_views.py ... + + ---------- coverage: platform darwin, python 3.7.4-final-0 ----------- + Name Stmts Miss Cover Missing + ---------------------------------------------------------------------------------- + tutorial/__init__.py 8 0 100% + tutorial/alembic/env.py 23 4 83% 28-30, 56 + tutorial/alembic/versions/20200106_8c274fe5f3c4.py 12 2 83% 31-32 + tutorial/models/__init__.py 32 2 94% 71, 82 + tutorial/models/meta.py 5 0 100% + tutorial/models/mymodel.py 8 0 100% + tutorial/pshell.py 7 5 29% 5-13 + tutorial/routes.py 3 0 100% + tutorial/scripts/__init__.py 0 0 100% + tutorial/scripts/initialize_db.py 22 14 36% 15-16, 20-25, 29-38 + tutorial/views/__init__.py 0 0 100% + tutorial/views/default.py 12 0 100% + tutorial/views/notfound.py 4 0 100% + ---------------------------------------------------------------------------------- + TOTAL 136 27 80% + + ===================== 5 passed in 0.77 seconds ====================== Our package doesn't quite have 100% test coverage. diff --git a/docs/tutorials/wiki2/src/basiclayout/.gitignore b/docs/tutorials/wiki2/src/basiclayout/.gitignore index 1853d983c..c612e59f2 100644 --- a/docs/tutorials/wiki2/src/basiclayout/.gitignore +++ b/docs/tutorials/wiki2/src/basiclayout/.gitignore @@ -19,3 +19,4 @@ Data.fs* .DS_Store coverage test +*.sqlite diff --git a/docs/tutorials/wiki2/src/basiclayout/testing.ini b/docs/tutorials/wiki2/src/basiclayout/testing.ini new file mode 100644 index 000000000..85e5e1ae9 --- /dev/null +++ b/docs/tutorials/wiki2/src/basiclayout/testing.ini @@ -0,0 +1,79 @@ +### +# app configuration +# https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/environment.html +### + +[app:main] +use = egg:tutorial + +pyramid.reload_templates = false +pyramid.debug_authorization = false +pyramid.debug_notfound = false +pyramid.debug_routematch = false +pyramid.default_locale_name = en + +sqlalchemy.url = sqlite:///%(here)s/testing.sqlite + +retry.attempts = 3 + +[pshell] +setup = tutorial.pshell.setup + +### +# wsgi server configuration +### + +[alembic] +# path to migration scripts +script_location = tutorial/alembic +file_template = %%(year)d%%(month).2d%%(day).2d_%%(rev)s +# file_template = %%(rev)s_%%(slug)s + +[server:main] +use = egg:waitress#main +listen = *:6543 + +### +# logging configuration +# https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/logging.html +### + +[loggers] +keys = root, tutorial, sqlalchemy, alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_tutorial] +level = WARN +handlers = +qualname = tutorial + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine +# "level = INFO" logs SQL queries. +# "level = DEBUG" logs SQL queries and results. +# "level = WARN" logs neither. (Recommended for production systems.) + +[logger_alembic] +level = WARN +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(asctime)s %(levelname)-5.5s [%(name)s:%(lineno)s][%(threadName)s] %(message)s diff --git a/docs/tutorials/wiki2/src/basiclayout/tests/conftest.py b/docs/tutorials/wiki2/src/basiclayout/tests/conftest.py new file mode 100644 index 000000000..2db65f887 --- /dev/null +++ b/docs/tutorials/wiki2/src/basiclayout/tests/conftest.py @@ -0,0 +1,125 @@ +import alembic +import alembic.config +import alembic.command +import os +from pyramid.paster import get_appsettings +from pyramid.scripting import prepare +from pyramid.testing import DummyRequest +import pytest +import transaction +from webob.cookies import Cookie +import webtest + +from tutorial import main +from tutorial import models +from tutorial.models.meta import Base + + +def pytest_addoption(parser): + parser.addoption('--ini', action='store', metavar='INI_FILE') + +@pytest.fixture(scope='session') +def ini_file(request): + # potentially grab this path from a pytest option + return os.path.abspath(request.config.option.ini or 'testing.ini') + +@pytest.fixture(scope='session') +def app_settings(ini_file): + return get_appsettings(ini_file) + +@pytest.fixture(scope='session') +def dbengine(app_settings, ini_file): + engine = models.get_engine(app_settings) + + alembic_cfg = alembic.config.Config(ini_file) + Base.metadata.drop_all(bind=engine) + alembic.command.stamp(alembic_cfg, None, purge=True) + + # run migrations to initialize the database + # depending on how we want to initialize the database from scratch + # we could alternatively call: + # Base.metadata.create_all(bind=engine) + # alembic.command.stamp(alembic_cfg, "head") + alembic.command.upgrade(alembic_cfg, "head") + + yield engine + + Base.metadata.drop_all(bind=engine) + alembic.command.stamp(alembic_cfg, None, purge=True) + +@pytest.fixture(scope='session') +def app(app_settings, dbengine): + return main({}, dbengine=dbengine, **app_settings) + +@pytest.fixture +def tm(): + tm = transaction.TransactionManager(explicit=True) + tm.begin() + tm.doom() + + yield tm + + tm.abort() + +@pytest.fixture +def dbsession(app, tm): + session_factory = app.registry['dbsession_factory'] + return models.get_tm_session(session_factory, tm) + +@pytest.fixture +def testapp(app, tm, dbsession): + # override request.dbsession and request.tm with our own + # externally-controlled values that are shared across requests but aborted + # at the end + testapp = webtest.TestApp(app, extra_environ={ + 'HTTP_HOST': 'example.com', + 'tm.active': True, + 'tm.manager': tm, + 'app.dbsession': dbsession, + }) + + return testapp + +@pytest.fixture +def app_request(app, tm, dbsession): + """ + A real request. + + This request is almost identical to a real request but it has some + drawbacks in tests as it's harder to mock data and is heavier. + + """ + env = prepare(registry=app.registry) + request = env['request'] + request.host = 'example.com' + + # without this, request.dbsession will be joined to the same transaction + # manager but it will be using a different sqlalchemy.orm.Session using + # a separate database transaction + request.dbsession = dbsession + request.tm = tm + + yield request + env['closer']() + +@pytest.fixture +def dummy_request(app, tm, dbsession): + """ + A lightweight dummy request. + + This request is ultra-lightweight and should be used only when the + request itself is not a large focus in the call-stack. + + It is way easier to mock and control side-effects using this object. + + - It does not have request extensions applied. + - Threadlocals are not properly pushed. + + """ + request = DummyRequest() + request.registry = app.registry + request.host = 'example.com' + request.dbsession = dbsession + request.tm = tm + + return request diff --git a/docs/tutorials/wiki2/src/basiclayout/tests/test_functional.py b/docs/tutorials/wiki2/src/basiclayout/tests/test_functional.py new file mode 100644 index 000000000..dbcd8aec7 --- /dev/null +++ b/docs/tutorials/wiki2/src/basiclayout/tests/test_functional.py @@ -0,0 +1,13 @@ +from tutorial import models + +def test_my_view_success(testapp, dbsession): + model = models.MyModel(name='one', value=55) + dbsession.add(model) + dbsession.flush() + + res = testapp.get('/', status=200) + assert res.body + +def test_notfound(testapp): + res = testapp.get('/badurl', status=404) + assert res.status_code == 404 diff --git a/docs/tutorials/wiki2/src/basiclayout/tests/test_it.py b/docs/tutorials/wiki2/src/basiclayout/tests/test_it.py deleted file mode 100644 index ea16534fc..000000000 --- a/docs/tutorials/wiki2/src/basiclayout/tests/test_it.py +++ /dev/null @@ -1,66 +0,0 @@ -import unittest - -from pyramid import testing - -import transaction - - -def dummy_request(dbsession): - return testing.DummyRequest(dbsession=dbsession) - - -class BaseTest(unittest.TestCase): - def setUp(self): - self.config = testing.setUp(settings={ - 'sqlalchemy.url': 'sqlite:///:memory:' - }) - self.config.include('tutorial.models') - settings = self.config.get_settings() - - from tutorial.models import ( - get_engine, - get_session_factory, - get_tm_session, - ) - - self.engine = get_engine(settings) - session_factory = get_session_factory(self.engine) - - self.session = get_tm_session(session_factory, transaction.manager) - - def init_database(self): - from tutorial.models.meta import Base - Base.metadata.create_all(self.engine) - - def tearDown(self): - from tutorial.models.meta import Base - - testing.tearDown() - transaction.abort() - Base.metadata.drop_all(self.engine) - - -class TestMyViewSuccessCondition(BaseTest): - - def setUp(self): - super(TestMyViewSuccessCondition, self).setUp() - self.init_database() - - from tutorial.models import MyModel - - model = MyModel(name='one', value=55) - self.session.add(model) - - def test_passing_view(self): - from tutorial.views.default import my_view - info = my_view(dummy_request(self.session)) - self.assertEqual(info['one'].name, 'one') - self.assertEqual(info['project'], 'myproj') - - -class TestMyViewFailureCondition(BaseTest): - - def test_failing_view(self): - from tutorial.views.default import my_view - info = my_view(dummy_request(self.session)) - self.assertEqual(info.status_int, 500) diff --git a/docs/tutorials/wiki2/src/basiclayout/tests/test_views.py b/docs/tutorials/wiki2/src/basiclayout/tests/test_views.py new file mode 100644 index 000000000..8ae464d03 --- /dev/null +++ b/docs/tutorials/wiki2/src/basiclayout/tests/test_views.py @@ -0,0 +1,23 @@ +from tutorial import models +from tutorial.views.default import my_view +from tutorial.views.notfound import notfound_view + + +def test_my_view_failure(app_request): + info = my_view(app_request) + assert info.status_int == 500 + +def test_my_view_success(app_request, dbsession): + model = models.MyModel(name='one', value=55) + dbsession.add(model) + dbsession.flush() + + info = my_view(app_request) + assert app_request.response.status_int == 200 + assert info['one'].name == 'one' + assert info['project'] == 'myproj' + +def test_notfound_view(app_request): + info = notfound_view(app_request) + assert app_request.response.status_int == 404 + assert info == {} diff --git a/docs/tutorials/wiki2/src/basiclayout/tutorial/__init__.py b/docs/tutorials/wiki2/src/basiclayout/tutorial/__init__.py index 5c2ba5cc0..7edc0957d 100644 --- a/docs/tutorials/wiki2/src/basiclayout/tutorial/__init__.py +++ b/docs/tutorials/wiki2/src/basiclayout/tutorial/__init__.py @@ -5,8 +5,8 @@ def main(global_config, **settings): """ This function returns a Pyramid WSGI application. """ with Configurator(settings=settings) as config: - config.include('.models') config.include('pyramid_jinja2') config.include('.routes') + config.include('.models') config.scan() return config.make_wsgi_app() diff --git a/docs/tutorials/wiki2/src/basiclayout/tutorial/models/__init__.py b/docs/tutorials/wiki2/src/basiclayout/tutorial/models/__init__.py index d8a273e9e..1c3ec5ee8 100644 --- a/docs/tutorials/wiki2/src/basiclayout/tutorial/models/__init__.py +++ b/docs/tutorials/wiki2/src/basiclayout/tutorial/models/__init__.py @@ -65,13 +65,21 @@ def includeme(config): # use pyramid_retry to retry a request when transient exceptions occur config.include('pyramid_retry') - session_factory = get_session_factory(get_engine(settings)) + # hook to share the dbengine fixture in testing + dbengine = settings.get('dbengine') + if not dbengine: + dbengine = get_engine(settings) + + session_factory = get_session_factory(dbengine) config.registry['dbsession_factory'] = session_factory # make request.dbsession available for use in Pyramid - config.add_request_method( - # r.tm is the transaction manager used by pyramid_tm - lambda r: get_tm_session(session_factory, r.tm), - 'dbsession', - reify=True - ) + def dbsession(request): + # hook to share the dbsession fixture in testing + dbsession = request.environ.get('app.dbsession') + if dbsession is None: + # request.tm is the transaction manager used by pyramid_tm + dbsession = get_tm_session(session_factory, request.tm) + return dbsession + + config.add_request_method(dbsession, reify=True) diff --git a/docs/tutorials/wiki2/src/basiclayout/tutorial/views/default.py b/docs/tutorials/wiki2/src/basiclayout/tutorial/views/default.py index 094b2f303..a0f654d38 100644 --- a/docs/tutorials/wiki2/src/basiclayout/tutorial/views/default.py +++ b/docs/tutorials/wiki2/src/basiclayout/tutorial/views/default.py @@ -1,7 +1,6 @@ from pyramid.view import view_config from pyramid.response import Response - -from sqlalchemy.exc import DBAPIError +from sqlalchemy.exc import SQLAlchemyError from .. import models @@ -10,8 +9,8 @@ from .. import models def my_view(request): try: query = request.dbsession.query(models.MyModel) - one = query.filter(models.MyModel.name == 'one').first() - except DBAPIError: + one = query.filter(models.MyModel.name == 'one').one() + except SQLAlchemyError: return Response(db_err_msg, content_type='text/plain', status=500) return {'one': one, 'project': 'myproj'} diff --git a/docs/tutorials/wiki2/src/installation/.gitignore b/docs/tutorials/wiki2/src/installation/.gitignore index 1853d983c..c612e59f2 100644 --- a/docs/tutorials/wiki2/src/installation/.gitignore +++ b/docs/tutorials/wiki2/src/installation/.gitignore @@ -19,3 +19,4 @@ Data.fs* .DS_Store coverage test +*.sqlite diff --git a/docs/tutorials/wiki2/src/installation/testing.ini b/docs/tutorials/wiki2/src/installation/testing.ini new file mode 100644 index 000000000..85e5e1ae9 --- /dev/null +++ b/docs/tutorials/wiki2/src/installation/testing.ini @@ -0,0 +1,79 @@ +### +# app configuration +# https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/environment.html +### + +[app:main] +use = egg:tutorial + +pyramid.reload_templates = false +pyramid.debug_authorization = false +pyramid.debug_notfound = false +pyramid.debug_routematch = false +pyramid.default_locale_name = en + +sqlalchemy.url = sqlite:///%(here)s/testing.sqlite + +retry.attempts = 3 + +[pshell] +setup = tutorial.pshell.setup + +### +# wsgi server configuration +### + +[alembic] +# path to migration scripts +script_location = tutorial/alembic +file_template = %%(year)d%%(month).2d%%(day).2d_%%(rev)s +# file_template = %%(rev)s_%%(slug)s + +[server:main] +use = egg:waitress#main +listen = *:6543 + +### +# logging configuration +# https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/logging.html +### + +[loggers] +keys = root, tutorial, sqlalchemy, alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_tutorial] +level = WARN +handlers = +qualname = tutorial + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine +# "level = INFO" logs SQL queries. +# "level = DEBUG" logs SQL queries and results. +# "level = WARN" logs neither. (Recommended for production systems.) + +[logger_alembic] +level = WARN +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(asctime)s %(levelname)-5.5s [%(name)s:%(lineno)s][%(threadName)s] %(message)s diff --git a/docs/tutorials/wiki2/src/installation/tests/conftest.py b/docs/tutorials/wiki2/src/installation/tests/conftest.py new file mode 100644 index 000000000..2db65f887 --- /dev/null +++ b/docs/tutorials/wiki2/src/installation/tests/conftest.py @@ -0,0 +1,125 @@ +import alembic +import alembic.config +import alembic.command +import os +from pyramid.paster import get_appsettings +from pyramid.scripting import prepare +from pyramid.testing import DummyRequest +import pytest +import transaction +from webob.cookies import Cookie +import webtest + +from tutorial import main +from tutorial import models +from tutorial.models.meta import Base + + +def pytest_addoption(parser): + parser.addoption('--ini', action='store', metavar='INI_FILE') + +@pytest.fixture(scope='session') +def ini_file(request): + # potentially grab this path from a pytest option + return os.path.abspath(request.config.option.ini or 'testing.ini') + +@pytest.fixture(scope='session') +def app_settings(ini_file): + return get_appsettings(ini_file) + +@pytest.fixture(scope='session') +def dbengine(app_settings, ini_file): + engine = models.get_engine(app_settings) + + alembic_cfg = alembic.config.Config(ini_file) + Base.metadata.drop_all(bind=engine) + alembic.command.stamp(alembic_cfg, None, purge=True) + + # run migrations to initialize the database + # depending on how we want to initialize the database from scratch + # we could alternatively call: + # Base.metadata.create_all(bind=engine) + # alembic.command.stamp(alembic_cfg, "head") + alembic.command.upgrade(alembic_cfg, "head") + + yield engine + + Base.metadata.drop_all(bind=engine) + alembic.command.stamp(alembic_cfg, None, purge=True) + +@pytest.fixture(scope='session') +def app(app_settings, dbengine): + return main({}, dbengine=dbengine, **app_settings) + +@pytest.fixture +def tm(): + tm = transaction.TransactionManager(explicit=True) + tm.begin() + tm.doom() + + yield tm + + tm.abort() + +@pytest.fixture +def dbsession(app, tm): + session_factory = app.registry['dbsession_factory'] + return models.get_tm_session(session_factory, tm) + +@pytest.fixture +def testapp(app, tm, dbsession): + # override request.dbsession and request.tm with our own + # externally-controlled values that are shared across requests but aborted + # at the end + testapp = webtest.TestApp(app, extra_environ={ + 'HTTP_HOST': 'example.com', + 'tm.active': True, + 'tm.manager': tm, + 'app.dbsession': dbsession, + }) + + return testapp + +@pytest.fixture +def app_request(app, tm, dbsession): + """ + A real request. + + This request is almost identical to a real request but it has some + drawbacks in tests as it's harder to mock data and is heavier. + + """ + env = prepare(registry=app.registry) + request = env['request'] + request.host = 'example.com' + + # without this, request.dbsession will be joined to the same transaction + # manager but it will be using a different sqlalchemy.orm.Session using + # a separate database transaction + request.dbsession = dbsession + request.tm = tm + + yield request + env['closer']() + +@pytest.fixture +def dummy_request(app, tm, dbsession): + """ + A lightweight dummy request. + + This request is ultra-lightweight and should be used only when the + request itself is not a large focus in the call-stack. + + It is way easier to mock and control side-effects using this object. + + - It does not have request extensions applied. + - Threadlocals are not properly pushed. + + """ + request = DummyRequest() + request.registry = app.registry + request.host = 'example.com' + request.dbsession = dbsession + request.tm = tm + + return request diff --git a/docs/tutorials/wiki2/src/installation/tests/test_functional.py b/docs/tutorials/wiki2/src/installation/tests/test_functional.py new file mode 100644 index 000000000..dbcd8aec7 --- /dev/null +++ b/docs/tutorials/wiki2/src/installation/tests/test_functional.py @@ -0,0 +1,13 @@ +from tutorial import models + +def test_my_view_success(testapp, dbsession): + model = models.MyModel(name='one', value=55) + dbsession.add(model) + dbsession.flush() + + res = testapp.get('/', status=200) + assert res.body + +def test_notfound(testapp): + res = testapp.get('/badurl', status=404) + assert res.status_code == 404 diff --git a/docs/tutorials/wiki2/src/installation/tests/test_it.py b/docs/tutorials/wiki2/src/installation/tests/test_it.py deleted file mode 100644 index ea16534fc..000000000 --- a/docs/tutorials/wiki2/src/installation/tests/test_it.py +++ /dev/null @@ -1,66 +0,0 @@ -import unittest - -from pyramid import testing - -import transaction - - -def dummy_request(dbsession): - return testing.DummyRequest(dbsession=dbsession) - - -class BaseTest(unittest.TestCase): - def setUp(self): - self.config = testing.setUp(settings={ - 'sqlalchemy.url': 'sqlite:///:memory:' - }) - self.config.include('tutorial.models') - settings = self.config.get_settings() - - from tutorial.models import ( - get_engine, - get_session_factory, - get_tm_session, - ) - - self.engine = get_engine(settings) - session_factory = get_session_factory(self.engine) - - self.session = get_tm_session(session_factory, transaction.manager) - - def init_database(self): - from tutorial.models.meta import Base - Base.metadata.create_all(self.engine) - - def tearDown(self): - from tutorial.models.meta import Base - - testing.tearDown() - transaction.abort() - Base.metadata.drop_all(self.engine) - - -class TestMyViewSuccessCondition(BaseTest): - - def setUp(self): - super(TestMyViewSuccessCondition, self).setUp() - self.init_database() - - from tutorial.models import MyModel - - model = MyModel(name='one', value=55) - self.session.add(model) - - def test_passing_view(self): - from tutorial.views.default import my_view - info = my_view(dummy_request(self.session)) - self.assertEqual(info['one'].name, 'one') - self.assertEqual(info['project'], 'myproj') - - -class TestMyViewFailureCondition(BaseTest): - - def test_failing_view(self): - from tutorial.views.default import my_view - info = my_view(dummy_request(self.session)) - self.assertEqual(info.status_int, 500) diff --git a/docs/tutorials/wiki2/src/installation/tests/test_views.py b/docs/tutorials/wiki2/src/installation/tests/test_views.py new file mode 100644 index 000000000..8ae464d03 --- /dev/null +++ b/docs/tutorials/wiki2/src/installation/tests/test_views.py @@ -0,0 +1,23 @@ +from tutorial import models +from tutorial.views.default import my_view +from tutorial.views.notfound import notfound_view + + +def test_my_view_failure(app_request): + info = my_view(app_request) + assert info.status_int == 500 + +def test_my_view_success(app_request, dbsession): + model = models.MyModel(name='one', value=55) + dbsession.add(model) + dbsession.flush() + + info = my_view(app_request) + assert app_request.response.status_int == 200 + assert info['one'].name == 'one' + assert info['project'] == 'myproj' + +def test_notfound_view(app_request): + info = notfound_view(app_request) + assert app_request.response.status_int == 404 + assert info == {} diff --git a/docs/tutorials/wiki2/src/installation/tutorial/__init__.py b/docs/tutorials/wiki2/src/installation/tutorial/__init__.py index 5c2ba5cc0..7edc0957d 100644 --- a/docs/tutorials/wiki2/src/installation/tutorial/__init__.py +++ b/docs/tutorials/wiki2/src/installation/tutorial/__init__.py @@ -5,8 +5,8 @@ def main(global_config, **settings): """ This function returns a Pyramid WSGI application. """ with Configurator(settings=settings) as config: - config.include('.models') config.include('pyramid_jinja2') config.include('.routes') + config.include('.models') config.scan() return config.make_wsgi_app() diff --git a/docs/tutorials/wiki2/src/installation/tutorial/models/__init__.py b/docs/tutorials/wiki2/src/installation/tutorial/models/__init__.py index d8a273e9e..1c3ec5ee8 100644 --- a/docs/tutorials/wiki2/src/installation/tutorial/models/__init__.py +++ b/docs/tutorials/wiki2/src/installation/tutorial/models/__init__.py @@ -65,13 +65,21 @@ def includeme(config): # use pyramid_retry to retry a request when transient exceptions occur config.include('pyramid_retry') - session_factory = get_session_factory(get_engine(settings)) + # hook to share the dbengine fixture in testing + dbengine = settings.get('dbengine') + if not dbengine: + dbengine = get_engine(settings) + + session_factory = get_session_factory(dbengine) config.registry['dbsession_factory'] = session_factory # make request.dbsession available for use in Pyramid - config.add_request_method( - # r.tm is the transaction manager used by pyramid_tm - lambda r: get_tm_session(session_factory, r.tm), - 'dbsession', - reify=True - ) + def dbsession(request): + # hook to share the dbsession fixture in testing + dbsession = request.environ.get('app.dbsession') + if dbsession is None: + # request.tm is the transaction manager used by pyramid_tm + dbsession = get_tm_session(session_factory, request.tm) + return dbsession + + config.add_request_method(dbsession, reify=True) diff --git a/docs/tutorials/wiki2/src/installation/tutorial/views/default.py b/docs/tutorials/wiki2/src/installation/tutorial/views/default.py index 094b2f303..a0f654d38 100644 --- a/docs/tutorials/wiki2/src/installation/tutorial/views/default.py +++ b/docs/tutorials/wiki2/src/installation/tutorial/views/default.py @@ -1,7 +1,6 @@ from pyramid.view import view_config from pyramid.response import Response - -from sqlalchemy.exc import DBAPIError +from sqlalchemy.exc import SQLAlchemyError from .. import models @@ -10,8 +9,8 @@ from .. import models def my_view(request): try: query = request.dbsession.query(models.MyModel) - one = query.filter(models.MyModel.name == 'one').first() - except DBAPIError: + one = query.filter(models.MyModel.name == 'one').one() + except SQLAlchemyError: return Response(db_err_msg, content_type='text/plain', status=500) return {'one': one, 'project': 'myproj'} diff --git a/docs/tutorials/wiki2/src/models/.gitignore b/docs/tutorials/wiki2/src/models/.gitignore index 1853d983c..c612e59f2 100644 --- a/docs/tutorials/wiki2/src/models/.gitignore +++ b/docs/tutorials/wiki2/src/models/.gitignore @@ -19,3 +19,4 @@ Data.fs* .DS_Store coverage test +*.sqlite diff --git a/docs/tutorials/wiki2/src/models/setup.py b/docs/tutorials/wiki2/src/models/setup.py index 60234751a..fbd848136 100644 --- a/docs/tutorials/wiki2/src/models/setup.py +++ b/docs/tutorials/wiki2/src/models/setup.py @@ -19,8 +19,8 @@ requires = [ 'pyramid_tm', 'SQLAlchemy', 'transaction', - 'zope.sqlalchemy', 'waitress', + 'zope.sqlalchemy', ] tests_require = [ diff --git a/docs/tutorials/wiki2/src/models/testing.ini b/docs/tutorials/wiki2/src/models/testing.ini new file mode 100644 index 000000000..85e5e1ae9 --- /dev/null +++ b/docs/tutorials/wiki2/src/models/testing.ini @@ -0,0 +1,79 @@ +### +# app configuration +# https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/environment.html +### + +[app:main] +use = egg:tutorial + +pyramid.reload_templates = false +pyramid.debug_authorization = false +pyramid.debug_notfound = false +pyramid.debug_routematch = false +pyramid.default_locale_name = en + +sqlalchemy.url = sqlite:///%(here)s/testing.sqlite + +retry.attempts = 3 + +[pshell] +setup = tutorial.pshell.setup + +### +# wsgi server configuration +### + +[alembic] +# path to migration scripts +script_location = tutorial/alembic +file_template = %%(year)d%%(month).2d%%(day).2d_%%(rev)s +# file_template = %%(rev)s_%%(slug)s + +[server:main] +use = egg:waitress#main +listen = *:6543 + +### +# logging configuration +# https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/logging.html +### + +[loggers] +keys = root, tutorial, sqlalchemy, alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_tutorial] +level = WARN +handlers = +qualname = tutorial + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine +# "level = INFO" logs SQL queries. +# "level = DEBUG" logs SQL queries and results. +# "level = WARN" logs neither. (Recommended for production systems.) + +[logger_alembic] +level = WARN +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(asctime)s %(levelname)-5.5s [%(name)s:%(lineno)s][%(threadName)s] %(message)s diff --git a/docs/tutorials/wiki2/src/models/tests/conftest.py b/docs/tutorials/wiki2/src/models/tests/conftest.py new file mode 100644 index 000000000..2db65f887 --- /dev/null +++ b/docs/tutorials/wiki2/src/models/tests/conftest.py @@ -0,0 +1,125 @@ +import alembic +import alembic.config +import alembic.command +import os +from pyramid.paster import get_appsettings +from pyramid.scripting import prepare +from pyramid.testing import DummyRequest +import pytest +import transaction +from webob.cookies import Cookie +import webtest + +from tutorial import main +from tutorial import models +from tutorial.models.meta import Base + + +def pytest_addoption(parser): + parser.addoption('--ini', action='store', metavar='INI_FILE') + +@pytest.fixture(scope='session') +def ini_file(request): + # potentially grab this path from a pytest option + return os.path.abspath(request.config.option.ini or 'testing.ini') + +@pytest.fixture(scope='session') +def app_settings(ini_file): + return get_appsettings(ini_file) + +@pytest.fixture(scope='session') +def dbengine(app_settings, ini_file): + engine = models.get_engine(app_settings) + + alembic_cfg = alembic.config.Config(ini_file) + Base.metadata.drop_all(bind=engine) + alembic.command.stamp(alembic_cfg, None, purge=True) + + # run migrations to initialize the database + # depending on how we want to initialize the database from scratch + # we could alternatively call: + # Base.metadata.create_all(bind=engine) + # alembic.command.stamp(alembic_cfg, "head") + alembic.command.upgrade(alembic_cfg, "head") + + yield engine + + Base.metadata.drop_all(bind=engine) + alembic.command.stamp(alembic_cfg, None, purge=True) + +@pytest.fixture(scope='session') +def app(app_settings, dbengine): + return main({}, dbengine=dbengine, **app_settings) + +@pytest.fixture +def tm(): + tm = transaction.TransactionManager(explicit=True) + tm.begin() + tm.doom() + + yield tm + + tm.abort() + +@pytest.fixture +def dbsession(app, tm): + session_factory = app.registry['dbsession_factory'] + return models.get_tm_session(session_factory, tm) + +@pytest.fixture +def testapp(app, tm, dbsession): + # override request.dbsession and request.tm with our own + # externally-controlled values that are shared across requests but aborted + # at the end + testapp = webtest.TestApp(app, extra_environ={ + 'HTTP_HOST': 'example.com', + 'tm.active': True, + 'tm.manager': tm, + 'app.dbsession': dbsession, + }) + + return testapp + +@pytest.fixture +def app_request(app, tm, dbsession): + """ + A real request. + + This request is almost identical to a real request but it has some + drawbacks in tests as it's harder to mock data and is heavier. + + """ + env = prepare(registry=app.registry) + request = env['request'] + request.host = 'example.com' + + # without this, request.dbsession will be joined to the same transaction + # manager but it will be using a different sqlalchemy.orm.Session using + # a separate database transaction + request.dbsession = dbsession + request.tm = tm + + yield request + env['closer']() + +@pytest.fixture +def dummy_request(app, tm, dbsession): + """ + A lightweight dummy request. + + This request is ultra-lightweight and should be used only when the + request itself is not a large focus in the call-stack. + + It is way easier to mock and control side-effects using this object. + + - It does not have request extensions applied. + - Threadlocals are not properly pushed. + + """ + request = DummyRequest() + request.registry = app.registry + request.host = 'example.com' + request.dbsession = dbsession + request.tm = tm + + return request diff --git a/docs/tutorials/wiki2/src/models/tests/test_functional.py b/docs/tutorials/wiki2/src/models/tests/test_functional.py new file mode 100644 index 000000000..dbcd8aec7 --- /dev/null +++ b/docs/tutorials/wiki2/src/models/tests/test_functional.py @@ -0,0 +1,13 @@ +from tutorial import models + +def test_my_view_success(testapp, dbsession): + model = models.MyModel(name='one', value=55) + dbsession.add(model) + dbsession.flush() + + res = testapp.get('/', status=200) + assert res.body + +def test_notfound(testapp): + res = testapp.get('/badurl', status=404) + assert res.status_code == 404 diff --git a/docs/tutorials/wiki2/src/models/tests/test_it.py b/docs/tutorials/wiki2/src/models/tests/test_it.py deleted file mode 100644 index ea16534fc..000000000 --- a/docs/tutorials/wiki2/src/models/tests/test_it.py +++ /dev/null @@ -1,66 +0,0 @@ -import unittest - -from pyramid import testing - -import transaction - - -def dummy_request(dbsession): - return testing.DummyRequest(dbsession=dbsession) - - -class BaseTest(unittest.TestCase): - def setUp(self): - self.config = testing.setUp(settings={ - 'sqlalchemy.url': 'sqlite:///:memory:' - }) - self.config.include('tutorial.models') - settings = self.config.get_settings() - - from tutorial.models import ( - get_engine, - get_session_factory, - get_tm_session, - ) - - self.engine = get_engine(settings) - session_factory = get_session_factory(self.engine) - - self.session = get_tm_session(session_factory, transaction.manager) - - def init_database(self): - from tutorial.models.meta import Base - Base.metadata.create_all(self.engine) - - def tearDown(self): - from tutorial.models.meta import Base - - testing.tearDown() - transaction.abort() - Base.metadata.drop_all(self.engine) - - -class TestMyViewSuccessCondition(BaseTest): - - def setUp(self): - super(TestMyViewSuccessCondition, self).setUp() - self.init_database() - - from tutorial.models import MyModel - - model = MyModel(name='one', value=55) - self.session.add(model) - - def test_passing_view(self): - from tutorial.views.default import my_view - info = my_view(dummy_request(self.session)) - self.assertEqual(info['one'].name, 'one') - self.assertEqual(info['project'], 'myproj') - - -class TestMyViewFailureCondition(BaseTest): - - def test_failing_view(self): - from tutorial.views.default import my_view - info = my_view(dummy_request(self.session)) - self.assertEqual(info.status_int, 500) diff --git a/docs/tutorials/wiki2/src/models/tests/test_views.py b/docs/tutorials/wiki2/src/models/tests/test_views.py new file mode 100644 index 000000000..8ae464d03 --- /dev/null +++ b/docs/tutorials/wiki2/src/models/tests/test_views.py @@ -0,0 +1,23 @@ +from tutorial import models +from tutorial.views.default import my_view +from tutorial.views.notfound import notfound_view + + +def test_my_view_failure(app_request): + info = my_view(app_request) + assert info.status_int == 500 + +def test_my_view_success(app_request, dbsession): + model = models.MyModel(name='one', value=55) + dbsession.add(model) + dbsession.flush() + + info = my_view(app_request) + assert app_request.response.status_int == 200 + assert info['one'].name == 'one' + assert info['project'] == 'myproj' + +def test_notfound_view(app_request): + info = notfound_view(app_request) + assert app_request.response.status_int == 404 + assert info == {} diff --git a/docs/tutorials/wiki2/src/models/tutorial/__init__.py b/docs/tutorials/wiki2/src/models/tutorial/__init__.py index 5c2ba5cc0..7edc0957d 100644 --- a/docs/tutorials/wiki2/src/models/tutorial/__init__.py +++ b/docs/tutorials/wiki2/src/models/tutorial/__init__.py @@ -5,8 +5,8 @@ def main(global_config, **settings): """ This function returns a Pyramid WSGI application. """ with Configurator(settings=settings) as config: - config.include('.models') config.include('pyramid_jinja2') config.include('.routes') + config.include('.models') config.scan() return config.make_wsgi_app() diff --git a/docs/tutorials/wiki2/src/models/tutorial/models/__init__.py b/docs/tutorials/wiki2/src/models/tutorial/models/__init__.py index a4209a6e9..47d77ef01 100644 --- a/docs/tutorials/wiki2/src/models/tutorial/models/__init__.py +++ b/docs/tutorials/wiki2/src/models/tutorial/models/__init__.py @@ -66,13 +66,21 @@ def includeme(config): # use pyramid_retry to retry a request when transient exceptions occur config.include('pyramid_retry') - session_factory = get_session_factory(get_engine(settings)) + # hook to share the dbengine fixture in testing + dbengine = settings.get('dbengine') + if not dbengine: + dbengine = get_engine(settings) + + session_factory = get_session_factory(dbengine) config.registry['dbsession_factory'] = session_factory # make request.dbsession available for use in Pyramid - config.add_request_method( - # r.tm is the transaction manager used by pyramid_tm - lambda r: get_tm_session(session_factory, r.tm), - 'dbsession', - reify=True - ) + def dbsession(request): + # hook to share the dbsession fixture in testing + dbsession = request.environ.get('app.dbsession') + if dbsession is None: + # request.tm is the transaction manager used by pyramid_tm + dbsession = get_tm_session(session_factory, request.tm) + return dbsession + + config.add_request_method(dbsession, reify=True) diff --git a/docs/tutorials/wiki2/src/models/tutorial/scripts/initialize_db.py b/docs/tutorials/wiki2/src/models/tutorial/scripts/initialize_db.py index e6350fb36..c8034e5a5 100644 --- a/docs/tutorials/wiki2/src/models/tutorial/scripts/initialize_db.py +++ b/docs/tutorials/wiki2/src/models/tutorial/scripts/initialize_db.py @@ -8,6 +8,10 @@ from .. import models def setup_models(dbsession): + """ + Add or update models / fixtures in the database. + + """ editor = models.User(name='editor', role='editor') editor.set_password('editor') dbsession.add(editor) diff --git a/docs/tutorials/wiki2/src/models/tutorial/views/default.py b/docs/tutorials/wiki2/src/models/tutorial/views/default.py index 094b2f303..a0f654d38 100644 --- a/docs/tutorials/wiki2/src/models/tutorial/views/default.py +++ b/docs/tutorials/wiki2/src/models/tutorial/views/default.py @@ -1,7 +1,6 @@ from pyramid.view import view_config from pyramid.response import Response - -from sqlalchemy.exc import DBAPIError +from sqlalchemy.exc import SQLAlchemyError from .. import models @@ -10,8 +9,8 @@ from .. import models def my_view(request): try: query = request.dbsession.query(models.MyModel) - one = query.filter(models.MyModel.name == 'one').first() - except DBAPIError: + one = query.filter(models.MyModel.name == 'one').one() + except SQLAlchemyError: return Response(db_err_msg, content_type='text/plain', status=500) return {'one': one, 'project': 'myproj'} -- cgit v1.2.3 From cd666082fbbd8b11d5cefd4a2d72209ae4f847be Mon Sep 17 00:00:00 2001 From: Michael Merickel Date: Mon, 6 Jan 2020 22:58:07 -0600 Subject: sync views with new structure and add csrf protection --- docs/tutorials/wiki2/definingviews.rst | 140 ++++++++++++--------- docs/tutorials/wiki2/src/views/.gitignore | 1 + docs/tutorials/wiki2/src/views/setup.py | 2 +- docs/tutorials/wiki2/src/views/testing.ini | 79 ++++++++++++ docs/tutorials/wiki2/src/views/tests/conftest.py | 125 ++++++++++++++++++ .../wiki2/src/views/tests/test_functional.py | 13 ++ docs/tutorials/wiki2/src/views/tests/test_it.py | 66 ---------- docs/tutorials/wiki2/src/views/tests/test_views.py | 23 ++++ .../tutorials/wiki2/src/views/tutorial/__init__.py | 3 +- .../wiki2/src/views/tutorial/models/__init__.py | 22 ++-- .../src/views/tutorial/scripts/initialize_db.py | 4 + .../tutorials/wiki2/src/views/tutorial/security.py | 6 + .../wiki2/src/views/tutorial/templates/404.jinja2 | 6 +- .../wiki2/src/views/tutorial/templates/edit.jinja2 | 3 +- .../src/views/tutorial/templates/layout.jinja2 | 9 ++ .../wiki2/src/views/tutorial/views/default.py | 23 ++-- 16 files changed, 375 insertions(+), 150 deletions(-) create mode 100644 docs/tutorials/wiki2/src/views/testing.ini create mode 100644 docs/tutorials/wiki2/src/views/tests/conftest.py create mode 100644 docs/tutorials/wiki2/src/views/tests/test_functional.py delete mode 100644 docs/tutorials/wiki2/src/views/tests/test_it.py create mode 100644 docs/tutorials/wiki2/src/views/tests/test_views.py create mode 100644 docs/tutorials/wiki2/src/views/tutorial/security.py (limited to 'docs/tutorials') diff --git a/docs/tutorials/wiki2/definingviews.rst b/docs/tutorials/wiki2/definingviews.rst index a434039ca..122164083 100644 --- a/docs/tutorials/wiki2/definingviews.rst +++ b/docs/tutorials/wiki2/definingviews.rst @@ -26,14 +26,15 @@ is not a dependency of the original "tutorial" application. We need to add a dependency on the ``docutils`` package to our ``tutorial`` package's ``setup.py`` file by assigning this dependency to the ``requires`` -parameter in the ``setup()`` function. +list. Open ``tutorial/setup.py`` and edit it to look like the following: .. literalinclude:: src/views/setup.py - :linenos: - :emphasize-lines: 14 - :language: python + :lines: 11-31 + :lineno-match: + :emphasize-lines: 4 + :language: python Only the highlighted line needs to be added. @@ -50,7 +51,7 @@ were provided at the time we created the project. As an example, the CSS file will be accessed via ``http://localhost:6543/static/theme.css`` by virtue of the call to the -``add_static_view`` directive we've made in the ``routes.py`` file. Any number +``add_static_view`` directive we've made in the ``tutorial/routes.py`` file. Any number and type of static assets can be placed in this directory (or subdirectories) and are just referred to by URL or by using the convenience method ``static_url``, e.g., ``request.static_url(':static/foo.css')`` within @@ -63,7 +64,7 @@ Adding routes to ``routes.py`` This is the `URL Dispatch` tutorial, so let's start by adding some URL patterns to our app. Later we'll attach views to handle the URLs. -The ``routes.py`` file contains :meth:`pyramid.config.Configurator.add_route` +The ``tutorial/routes.py`` file contains :meth:`pyramid.config.Configurator.add_route` calls which serve to add routes to our application. First we'll get rid of the existing route created by the template using the name ``'home'``. It's only an example and isn't relevant to our application. @@ -96,13 +97,13 @@ order they're registered. decorator attached to the ``edit_page`` view function, which in turn will be indicated by ``route_name='edit_page'``. -As a result of our edits, the ``routes.py`` file should look like the +As a result of our edits, the ``tutorial/routes.py`` file should look like the following: .. literalinclude:: src/views/tutorial/routes.py - :linenos: - :emphasize-lines: 3-6 - :language: python + :linenos: + :emphasize-lines: 3-6 + :language: python The highlighted lines are the ones that need to be added or edited. @@ -117,18 +118,41 @@ The highlighted lines are the ones that need to be added or edited. behavior in your own apps. +CSRF protection +=============== + +When handling HTML forms that mutate data in our database we need to verify that the form submission is legitimate and not from a URL embedded in a third-party website. +This is done by adding a unique token to each form that a third-party could not easily guess. +Read more about CSRF at :ref:`csrf_protection`. +For this tutorial, we'll store the active CSRF token in a cookie. + +Let's add a new ``tutorial/security.py`` file: + +.. literalinclude:: src/views/tutorial/security.py + :linenos: + :emphasize-lines: 5-6 + :language: python + +Since we've added a new ``tutorial/security.py`` module, we need to include it. +Open the file ``tutorial/__init__.py`` and edit the following lines: + +.. literalinclude:: src/views/tutorial/__init__.py + :linenos: + :emphasize-lines: 9 + :language: python + +On forms that mutate data, we'll be sure to add the CSRF token to the form, using :func:`pyramid.csrf.get_csrf_token`. + + Adding view functions in ``views/default.py`` ============================================= It's time for a major change. Open ``tutorial/views/default.py`` and -edit it to look like the following: +replace it with the following: .. literalinclude:: src/views/tutorial/views/default.py - :linenos: - :language: python - :emphasize-lines: 1-9,14- - -The highlighted lines need to be added or edited. + :linenos: + :language: python We added some imports, and created a regular expression to find "WikiWords". @@ -137,7 +161,7 @@ when originally rendered after we selected the ``sqlalchemy`` backend option in the cookiecutter. It was only an example and isn't relevant to our application. We also deleted the ``db_err_msg`` string. -Then we added four :term:`view callable` functions to our ``views/default.py`` +Then we added four :term:`view callable` functions to our ``tutorial/views/default.py`` module, as mentioned in the previous step: * ``view_wiki()`` - Displays the wiki itself. It will answer on the root URL. @@ -163,10 +187,10 @@ The ``view_wiki`` view function Following is the code for the ``view_wiki`` view function and its decorator: .. literalinclude:: src/views/tutorial/views/default.py - :lines: 17-20 - :lineno-match: - :linenos: - :language: python + :lines: 16-19 + :lineno-match: + :linenos: + :language: python ``view_wiki()`` is the :term:`default view` that gets called when a request is made to the root URL of our wiki. It always redirects to a URL which @@ -174,12 +198,12 @@ represents the path to our "FrontPage". The ``view_wiki`` view callable always redirects to the URL of a Page resource named "FrontPage". To do so, it returns an instance of the -:class:`pyramid.httpexceptions.HTTPFound` class (instances of which implement +:class:`pyramid.httpexceptions.HTTPSeeOther` class (instances of which implement the :class:`pyramid.interfaces.IResponse` interface, like :class:`pyramid.response.Response`). It uses the :meth:`pyramid.request.Request.route_url` API to construct a URL to the ``FrontPage`` page (i.e., ``http://localhost:6543/FrontPage``), and uses it as -the "location" of the ``HTTPFound`` response, forming an HTTP redirect. +the "location" of the ``HTTPSeeOther`` response, forming an HTTP redirect. The ``view_page`` view function @@ -188,10 +212,10 @@ The ``view_page`` view function Here is the code for the ``view_page`` view function and its decorator: .. literalinclude:: src/views/tutorial/views/default.py - :lines: 22-42 - :lineno-match: - :linenos: - :language: python + :lines: 21-41 + :lineno-match: + :linenos: + :language: python ``view_page()`` is used to display a single page of our wiki. It renders the :term:`reStructuredText` body of a page (stored as the ``data`` attribute of a @@ -241,10 +265,10 @@ The ``edit_page`` view function Here is the code for the ``edit_page`` view function and its decorator: .. literalinclude:: src/views/tutorial/views/default.py - :lines: 44-56 - :lineno-match: - :linenos: - :language: python + :lines: 43-55 + :lineno-match: + :linenos: + :language: python ``edit_page()`` is invoked when a user clicks the "Edit this Page" button on the view form. It renders an edit form, but it also acts as the handler for the @@ -252,14 +276,13 @@ form which it renders. The ``matchdict`` attribute of the request passed to the ``edit_page`` view will have a ``'pagename'`` key matching the name of the page that the user wants to edit. -If the view execution *is* a result of a form submission (i.e., the expression -``'form.submitted' in request.params`` is ``True``), the view grabs the +If the view execution *is* a result of a form submission (i.e., ``request.method == 'POST'``), the view grabs the ``body`` element of the request parameters and sets it as the ``data`` attribute of the page object. It then redirects to the ``view_page`` view of the wiki page. If the view execution is *not* a result of a form submission (i.e., the -expression ``'form.submitted' in request.params`` is ``False``), the view +expression ``request.method != 'POST'``), the view simply renders the edit form, passing the page object and a ``save_url`` which will be used as the action of the generated form. @@ -279,10 +302,10 @@ The ``add_page`` view function Here is the code for the ``add_page`` view function and its decorator: .. literalinclude:: src/views/tutorial/views/default.py - :lines: 58- - :lineno-match: - :linenos: - :language: python + :lines: 57- + :lineno-match: + :linenos: + :language: python ``add_page()`` is invoked when a user clicks on a *WikiWord* which isn't yet represented as a page in the system. The ``add_link`` function within the @@ -301,7 +324,7 @@ the database. If it already exists, then the client is redirected to the ``edit_page`` view, else we continue to the next check. If the view execution *is* a result of a form submission (i.e., the expression -``'form.submitted' in request.params`` is ``True``), we grab the page body from +``request.method == 'POST'``), we grab the page body from the form data, create a Page object with this page body and the name taken from ``matchdict['pagename']``, and save it into the database using ``request.dbession.add``. Since we have not yet covered authentication, we @@ -312,7 +335,7 @@ Finally, we redirect the client back to the ``view_page`` view for the newly created page. If the view execution is *not* a result of a form submission (i.e., the -expression ``'form.submitted' in request.params`` is ``False``), the view +expression ``request.method != 'POST'`` is ``False``), the view callable renders a template. To do so, it generates a ``save_url`` which the template uses as the form post URL during rendering. We're lazy here, so we're going to use the same template (``templates/edit.jinja2``) for the add @@ -339,9 +362,9 @@ Update ``tutorial/templates/layout.jinja2`` with the following content, as indicated by the emphasized lines: .. literalinclude:: src/views/tutorial/templates/layout.jinja2 - :linenos: - :emphasize-lines: 11,35-37 - :language: html + :linenos: + :emphasize-lines: 11,35-37 + :language: html Since we're using a templating engine, we can factor common boilerplate out of our page templates into reusable components. One method for doing this is @@ -350,8 +373,7 @@ template inheritance via blocks. - We have defined two placeholders in the layout template where a child template can override the content. These blocks are named ``subtitle`` (line 11) and ``content`` (line 36). -- Please refer to the `Jinja2 documentation `_ for more information about template - inheritance. +- Please refer to the `Jinja2 documentation `_ for more information about template inheritance. The ``view.jinja2`` template @@ -360,8 +382,8 @@ The ``view.jinja2`` template Create ``tutorial/templates/view.jinja2`` and add the following content: .. literalinclude:: src/views/tutorial/templates/view.jinja2 - :linenos: - :language: html + :linenos: + :language: html This template is used by ``view_page()`` for displaying a single wiki page. @@ -384,9 +406,9 @@ The ``edit.jinja2`` template Create ``tutorial/templates/edit.jinja2`` and add the following content: .. literalinclude:: src/views/tutorial/templates/edit.jinja2 - :linenos: - :emphasize-lines: 1,3,12,14,17 - :language: html + :linenos: + :emphasize-lines: 1,3,12,13,15,18 + :language: html This template serves two use cases. It is used by ``add_page()`` and ``edit_page()`` for adding and editing a wiki page. It displays a page @@ -396,11 +418,13 @@ containing a form and which provides the following: of the page (line 1). - Override the ``subtitle`` block to affect the ```` tag in the ``head`` of the page (line 3). +- Add the CSRF token to the form (line 13). + Without this line, attempts to edit the page would result in a ``400 Bad Request`` error. - A 10-row by 60-column ``textarea`` field named ``body`` that is filled with - any existing page data when it is rendered (line 14). -- A submit button that has the name ``form.submitted`` (line 17). + any existing page data when it is rendered (line 15). +- A submit button (line 18). - The form POSTs back to the ``save_url`` argument supplied by the view (line - 12). The view will use the ``body`` and ``form.submitted`` values. + 12). The view will use the ``body`` value. The ``404.jinja2`` template @@ -409,16 +433,16 @@ The ``404.jinja2`` template Replace ``tutorial/templates/404.jinja2`` with the following content: .. literalinclude:: src/views/tutorial/templates/404.jinja2 - :linenos: - :language: html + :linenos: + :language: html This template is linked from the ``notfound_view`` defined in ``tutorial/views/notfound.py`` as shown here: .. literalinclude:: src/views/tutorial/views/notfound.py - :linenos: - :emphasize-lines: 6 - :language: python + :linenos: + :emphasize-lines: 6 + :language: python There are several important things to note about this configuration: diff --git a/docs/tutorials/wiki2/src/views/.gitignore b/docs/tutorials/wiki2/src/views/.gitignore index 1853d983c..c612e59f2 100644 --- a/docs/tutorials/wiki2/src/views/.gitignore +++ b/docs/tutorials/wiki2/src/views/.gitignore @@ -19,3 +19,4 @@ Data.fs* .DS_Store coverage test +*.sqlite diff --git a/docs/tutorials/wiki2/src/views/setup.py b/docs/tutorials/wiki2/src/views/setup.py index 500c5e599..12eabaff2 100644 --- a/docs/tutorials/wiki2/src/views/setup.py +++ b/docs/tutorials/wiki2/src/views/setup.py @@ -20,8 +20,8 @@ requires = [ 'pyramid_tm', 'SQLAlchemy', 'transaction', - 'zope.sqlalchemy', 'waitress', + 'zope.sqlalchemy', ] tests_require = [ diff --git a/docs/tutorials/wiki2/src/views/testing.ini b/docs/tutorials/wiki2/src/views/testing.ini new file mode 100644 index 000000000..85e5e1ae9 --- /dev/null +++ b/docs/tutorials/wiki2/src/views/testing.ini @@ -0,0 +1,79 @@ +### +# app configuration +# https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/environment.html +### + +[app:main] +use = egg:tutorial + +pyramid.reload_templates = false +pyramid.debug_authorization = false +pyramid.debug_notfound = false +pyramid.debug_routematch = false +pyramid.default_locale_name = en + +sqlalchemy.url = sqlite:///%(here)s/testing.sqlite + +retry.attempts = 3 + +[pshell] +setup = tutorial.pshell.setup + +### +# wsgi server configuration +### + +[alembic] +# path to migration scripts +script_location = tutorial/alembic +file_template = %%(year)d%%(month).2d%%(day).2d_%%(rev)s +# file_template = %%(rev)s_%%(slug)s + +[server:main] +use = egg:waitress#main +listen = *:6543 + +### +# logging configuration +# https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/logging.html +### + +[loggers] +keys = root, tutorial, sqlalchemy, alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_tutorial] +level = WARN +handlers = +qualname = tutorial + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine +# "level = INFO" logs SQL queries. +# "level = DEBUG" logs SQL queries and results. +# "level = WARN" logs neither. (Recommended for production systems.) + +[logger_alembic] +level = WARN +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(asctime)s %(levelname)-5.5s [%(name)s:%(lineno)s][%(threadName)s] %(message)s diff --git a/docs/tutorials/wiki2/src/views/tests/conftest.py b/docs/tutorials/wiki2/src/views/tests/conftest.py new file mode 100644 index 000000000..2db65f887 --- /dev/null +++ b/docs/tutorials/wiki2/src/views/tests/conftest.py @@ -0,0 +1,125 @@ +import alembic +import alembic.config +import alembic.command +import os +from pyramid.paster import get_appsettings +from pyramid.scripting import prepare +from pyramid.testing import DummyRequest +import pytest +import transaction +from webob.cookies import Cookie +import webtest + +from tutorial import main +from tutorial import models +from tutorial.models.meta import Base + + +def pytest_addoption(parser): + parser.addoption('--ini', action='store', metavar='INI_FILE') + +@pytest.fixture(scope='session') +def ini_file(request): + # potentially grab this path from a pytest option + return os.path.abspath(request.config.option.ini or 'testing.ini') + +@pytest.fixture(scope='session') +def app_settings(ini_file): + return get_appsettings(ini_file) + +@pytest.fixture(scope='session') +def dbengine(app_settings, ini_file): + engine = models.get_engine(app_settings) + + alembic_cfg = alembic.config.Config(ini_file) + Base.metadata.drop_all(bind=engine) + alembic.command.stamp(alembic_cfg, None, purge=True) + + # run migrations to initialize the database + # depending on how we want to initialize the database from scratch + # we could alternatively call: + # Base.metadata.create_all(bind=engine) + # alembic.command.stamp(alembic_cfg, "head") + alembic.command.upgrade(alembic_cfg, "head") + + yield engine + + Base.metadata.drop_all(bind=engine) + alembic.command.stamp(alembic_cfg, None, purge=True) + +@pytest.fixture(scope='session') +def app(app_settings, dbengine): + return main({}, dbengine=dbengine, **app_settings) + +@pytest.fixture +def tm(): + tm = transaction.TransactionManager(explicit=True) + tm.begin() + tm.doom() + + yield tm + + tm.abort() + +@pytest.fixture +def dbsession(app, tm): + session_factory = app.registry['dbsession_factory'] + return models.get_tm_session(session_factory, tm) + +@pytest.fixture +def testapp(app, tm, dbsession): + # override request.dbsession and request.tm with our own + # externally-controlled values that are shared across requests but aborted + # at the end + testapp = webtest.TestApp(app, extra_environ={ + 'HTTP_HOST': 'example.com', + 'tm.active': True, + 'tm.manager': tm, + 'app.dbsession': dbsession, + }) + + return testapp + +@pytest.fixture +def app_request(app, tm, dbsession): + """ + A real request. + + This request is almost identical to a real request but it has some + drawbacks in tests as it's harder to mock data and is heavier. + + """ + env = prepare(registry=app.registry) + request = env['request'] + request.host = 'example.com' + + # without this, request.dbsession will be joined to the same transaction + # manager but it will be using a different sqlalchemy.orm.Session using + # a separate database transaction + request.dbsession = dbsession + request.tm = tm + + yield request + env['closer']() + +@pytest.fixture +def dummy_request(app, tm, dbsession): + """ + A lightweight dummy request. + + This request is ultra-lightweight and should be used only when the + request itself is not a large focus in the call-stack. + + It is way easier to mock and control side-effects using this object. + + - It does not have request extensions applied. + - Threadlocals are not properly pushed. + + """ + request = DummyRequest() + request.registry = app.registry + request.host = 'example.com' + request.dbsession = dbsession + request.tm = tm + + return request diff --git a/docs/tutorials/wiki2/src/views/tests/test_functional.py b/docs/tutorials/wiki2/src/views/tests/test_functional.py new file mode 100644 index 000000000..dbcd8aec7 --- /dev/null +++ b/docs/tutorials/wiki2/src/views/tests/test_functional.py @@ -0,0 +1,13 @@ +from tutorial import models + +def test_my_view_success(testapp, dbsession): + model = models.MyModel(name='one', value=55) + dbsession.add(model) + dbsession.flush() + + res = testapp.get('/', status=200) + assert res.body + +def test_notfound(testapp): + res = testapp.get('/badurl', status=404) + assert res.status_code == 404 diff --git a/docs/tutorials/wiki2/src/views/tests/test_it.py b/docs/tutorials/wiki2/src/views/tests/test_it.py deleted file mode 100644 index ea16534fc..000000000 --- a/docs/tutorials/wiki2/src/views/tests/test_it.py +++ /dev/null @@ -1,66 +0,0 @@ -import unittest - -from pyramid import testing - -import transaction - - -def dummy_request(dbsession): - return testing.DummyRequest(dbsession=dbsession) - - -class BaseTest(unittest.TestCase): - def setUp(self): - self.config = testing.setUp(settings={ - 'sqlalchemy.url': 'sqlite:///:memory:' - }) - self.config.include('tutorial.models') - settings = self.config.get_settings() - - from tutorial.models import ( - get_engine, - get_session_factory, - get_tm_session, - ) - - self.engine = get_engine(settings) - session_factory = get_session_factory(self.engine) - - self.session = get_tm_session(session_factory, transaction.manager) - - def init_database(self): - from tutorial.models.meta import Base - Base.metadata.create_all(self.engine) - - def tearDown(self): - from tutorial.models.meta import Base - - testing.tearDown() - transaction.abort() - Base.metadata.drop_all(self.engine) - - -class TestMyViewSuccessCondition(BaseTest): - - def setUp(self): - super(TestMyViewSuccessCondition, self).setUp() - self.init_database() - - from tutorial.models import MyModel - - model = MyModel(name='one', value=55) - self.session.add(model) - - def test_passing_view(self): - from tutorial.views.default import my_view - info = my_view(dummy_request(self.session)) - self.assertEqual(info['one'].name, 'one') - self.assertEqual(info['project'], 'myproj') - - -class TestMyViewFailureCondition(BaseTest): - - def test_failing_view(self): - from tutorial.views.default import my_view - info = my_view(dummy_request(self.session)) - self.assertEqual(info.status_int, 500) diff --git a/docs/tutorials/wiki2/src/views/tests/test_views.py b/docs/tutorials/wiki2/src/views/tests/test_views.py new file mode 100644 index 000000000..8ae464d03 --- /dev/null +++ b/docs/tutorials/wiki2/src/views/tests/test_views.py @@ -0,0 +1,23 @@ +from tutorial import models +from tutorial.views.default import my_view +from tutorial.views.notfound import notfound_view + + +def test_my_view_failure(app_request): + info = my_view(app_request) + assert info.status_int == 500 + +def test_my_view_success(app_request, dbsession): + model = models.MyModel(name='one', value=55) + dbsession.add(model) + dbsession.flush() + + info = my_view(app_request) + assert app_request.response.status_int == 200 + assert info['one'].name == 'one' + assert info['project'] == 'myproj' + +def test_notfound_view(app_request): + info = notfound_view(app_request) + assert app_request.response.status_int == 404 + assert info == {} diff --git a/docs/tutorials/wiki2/src/views/tutorial/__init__.py b/docs/tutorials/wiki2/src/views/tutorial/__init__.py index 5c2ba5cc0..81a22c68c 100644 --- a/docs/tutorials/wiki2/src/views/tutorial/__init__.py +++ b/docs/tutorials/wiki2/src/views/tutorial/__init__.py @@ -5,8 +5,9 @@ def main(global_config, **settings): """ This function returns a Pyramid WSGI application. """ with Configurator(settings=settings) as config: - config.include('.models') config.include('pyramid_jinja2') + config.include('.security') config.include('.routes') + config.include('.models') config.scan() return config.make_wsgi_app() diff --git a/docs/tutorials/wiki2/src/views/tutorial/models/__init__.py b/docs/tutorials/wiki2/src/views/tutorial/models/__init__.py index a4209a6e9..47d77ef01 100644 --- a/docs/tutorials/wiki2/src/views/tutorial/models/__init__.py +++ b/docs/tutorials/wiki2/src/views/tutorial/models/__init__.py @@ -66,13 +66,21 @@ def includeme(config): # use pyramid_retry to retry a request when transient exceptions occur config.include('pyramid_retry') - session_factory = get_session_factory(get_engine(settings)) + # hook to share the dbengine fixture in testing + dbengine = settings.get('dbengine') + if not dbengine: + dbengine = get_engine(settings) + + session_factory = get_session_factory(dbengine) config.registry['dbsession_factory'] = session_factory # make request.dbsession available for use in Pyramid - config.add_request_method( - # r.tm is the transaction manager used by pyramid_tm - lambda r: get_tm_session(session_factory, r.tm), - 'dbsession', - reify=True - ) + def dbsession(request): + # hook to share the dbsession fixture in testing + dbsession = request.environ.get('app.dbsession') + if dbsession is None: + # request.tm is the transaction manager used by pyramid_tm + dbsession = get_tm_session(session_factory, request.tm) + return dbsession + + config.add_request_method(dbsession, reify=True) diff --git a/docs/tutorials/wiki2/src/views/tutorial/scripts/initialize_db.py b/docs/tutorials/wiki2/src/views/tutorial/scripts/initialize_db.py index e6350fb36..c8034e5a5 100644 --- a/docs/tutorials/wiki2/src/views/tutorial/scripts/initialize_db.py +++ b/docs/tutorials/wiki2/src/views/tutorial/scripts/initialize_db.py @@ -8,6 +8,10 @@ from .. import models def setup_models(dbsession): + """ + Add or update models / fixtures in the database. + + """ editor = models.User(name='editor', role='editor') editor.set_password('editor') dbsession.add(editor) diff --git a/docs/tutorials/wiki2/src/views/tutorial/security.py b/docs/tutorials/wiki2/src/views/tutorial/security.py new file mode 100644 index 000000000..216894e07 --- /dev/null +++ b/docs/tutorials/wiki2/src/views/tutorial/security.py @@ -0,0 +1,6 @@ +from pyramid.csrf import CookieCSRFStoragePolicy + + +def includeme(config): + config.set_csrf_storage_policy(CookieCSRFStoragePolicy()) + config.set_default_csrf_options(require_csrf=True) diff --git a/docs/tutorials/wiki2/src/views/tutorial/templates/404.jinja2 b/docs/tutorials/wiki2/src/views/tutorial/templates/404.jinja2 index aaf12413f..5edb15285 100644 --- a/docs/tutorials/wiki2/src/views/tutorial/templates/404.jinja2 +++ b/docs/tutorials/wiki2/src/views/tutorial/templates/404.jinja2 @@ -1,8 +1,6 @@ {% extends "layout.jinja2" %} {% block content %} -<div class="content"> - <h1><span class="font-semi-bold">Pyramid</span> <span class="smaller">Starter project</span></h1> - <p class="lead"><span class="font-semi-bold">404</span> Page Not Found</p> -</div> +<h1><span class="font-semi-bold">Pyramid</span> <span class="smaller">Starter project</span></h1> +<p class="lead"><span class="font-semi-bold">404</span> Page Not Found</p> {% endblock content %} diff --git a/docs/tutorials/wiki2/src/views/tutorial/templates/edit.jinja2 b/docs/tutorials/wiki2/src/views/tutorial/templates/edit.jinja2 index 7db25c674..27b545054 100644 --- a/docs/tutorials/wiki2/src/views/tutorial/templates/edit.jinja2 +++ b/docs/tutorials/wiki2/src/views/tutorial/templates/edit.jinja2 @@ -10,11 +10,12 @@ Editing <strong>{{pagename}}</strong> <a href="{{request.route_url('view_page', pagename='FrontPage')}}">FrontPage</a>. </p> <form action="{{ save_url }}" method="post"> +<input type="hidden" name="csrf_token" value="{{ get_csrf_token() }}"> <div class="form-group"> <textarea class="form-control" name="body" rows="10" cols="60">{{ pagedata }}</textarea> </div> <div class="form-group"> - <button type="submit" name="form.submitted" value="Save" class="btn btn-default">Save</button> + <button type="submit" class="btn btn-default">Save</button> </div> </form> {% endblock content %} diff --git a/docs/tutorials/wiki2/src/views/tutorial/templates/layout.jinja2 b/docs/tutorials/wiki2/src/views/tutorial/templates/layout.jinja2 index 80062cbff..17e8f7688 100644 --- a/docs/tutorials/wiki2/src/views/tutorial/templates/layout.jinja2 +++ b/docs/tutorials/wiki2/src/views/tutorial/templates/layout.jinja2 @@ -37,6 +37,15 @@ </div> </div> </div> + <div class="row"> + <div class="links"> + <ul> + <li><i class="glyphicon glyphicon-cog icon-muted"></i><a href="https://github.com/Pylons/pyramid">Github Project</a></li> + <li><i class="glyphicon glyphicon-globe icon-muted"></i><a href="https://webchat.freenode.net/?channels=pyramid">IRC Channel</a></li> + <li><i class="glyphicon glyphicon-home icon-muted"></i><a href="https://pylonsproject.org">Pylons Project</a></li> + </ul> + </div> + </div> <div class="row"> <div class="copyright"> Copyright © Pylons Project diff --git a/docs/tutorials/wiki2/src/views/tutorial/views/default.py b/docs/tutorials/wiki2/src/views/tutorial/views/default.py index 867ba3f6c..ab6f571ca 100644 --- a/docs/tutorials/wiki2/src/views/tutorial/views/default.py +++ b/docs/tutorials/wiki2/src/views/tutorial/views/default.py @@ -1,23 +1,22 @@ from html import escape -import re from docutils.core import publish_parts - from pyramid.httpexceptions import ( - HTTPFound, HTTPNotFound, - ) - + HTTPSeeOther, +) from pyramid.view import view_config +import re from .. import models + # regular expression used to find WikiWords wikiwords = re.compile(r"\b([A-Z]\w+[A-Z]+\w+)") @view_config(route_name='view_wiki') def view_wiki(request): next_url = request.route_url('view_page', pagename='FrontPage') - return HTTPFound(location=next_url) + return HTTPSeeOther(location=next_url) @view_config(route_name='view_page', renderer='tutorial:templates/view.jinja2') def view_page(request): @@ -45,29 +44,29 @@ def view_page(request): def edit_page(request): pagename = request.matchdict['pagename'] page = request.dbsession.query(models.Page).filter_by(name=pagename).one() - if 'form.submitted' in request.params: + if request.method == 'POST': page.data = request.params['body'] next_url = request.route_url('view_page', pagename=page.name) - return HTTPFound(location=next_url) + return HTTPSeeOther(location=next_url) return dict( pagename=page.name, pagedata=page.data, save_url=request.route_url('edit_page', pagename=page.name), - ) + ) @view_config(route_name='add_page', renderer='tutorial:templates/edit.jinja2') def add_page(request): pagename = request.matchdict['pagename'] if request.dbsession.query(models.Page).filter_by(name=pagename).count() > 0: next_url = request.route_url('edit_page', pagename=pagename) - return HTTPFound(location=next_url) - if 'form.submitted' in request.params: + return HTTPSeeOther(location=next_url) + if request.method == 'POST': body = request.params['body'] page = models.Page(name=pagename, data=body) page.creator = ( request.dbsession.query(models.User).filter_by(name='editor').one()) request.dbsession.add(page) next_url = request.route_url('view_page', pagename=pagename) - return HTTPFound(location=next_url) + return HTTPSeeOther(location=next_url) save_url = request.route_url('add_page', pagename=pagename) return dict(pagename=pagename, pagedata='', save_url=save_url) -- cgit v1.2.3 From c4626765913de97fb6410f0fdb50a4c93a38bd5b Mon Sep 17 00:00:00 2001 From: Michael Merickel <michael@merickel.org> Date: Mon, 6 Jan 2020 22:31:40 -0600 Subject: update authentication docs with security policy --- docs/tutorials/wiki2/authentication.rst | 136 ++++++++++----------- docs/tutorials/wiki2/src/authentication/.gitignore | 1 + docs/tutorials/wiki2/src/authentication/setup.py | 2 +- .../tutorials/wiki2/src/authentication/testing.ini | 81 ++++++++++++ .../wiki2/src/authentication/tests/conftest.py | 125 +++++++++++++++++++ .../src/authentication/tests/test_functional.py | 13 ++ .../wiki2/src/authentication/tests/test_it.py | 66 ---------- .../wiki2/src/authentication/tests/test_views.py | 23 ++++ .../wiki2/src/authentication/tutorial/__init__.py | 4 +- .../src/authentication/tutorial/models/__init__.py | 22 ++-- .../tutorial/scripts/initialize_db.py | 4 + .../wiki2/src/authentication/tutorial/security.py | 46 ++++--- .../authentication/tutorial/templates/403.jinja2 | 6 + .../authentication/tutorial/templates/404.jinja2 | 6 +- .../authentication/tutorial/templates/edit.jinja2 | 3 +- .../tutorial/templates/layout.jinja2 | 19 ++- .../authentication/tutorial/templates/login.jinja2 | 3 +- .../src/authentication/tutorial/views/auth.py | 41 +++++-- .../src/authentication/tutorial/views/default.py | 23 ++-- 19 files changed, 428 insertions(+), 196 deletions(-) create mode 100644 docs/tutorials/wiki2/src/authentication/testing.ini create mode 100644 docs/tutorials/wiki2/src/authentication/tests/conftest.py create mode 100644 docs/tutorials/wiki2/src/authentication/tests/test_functional.py delete mode 100644 docs/tutorials/wiki2/src/authentication/tests/test_it.py create mode 100644 docs/tutorials/wiki2/src/authentication/tests/test_views.py create mode 100644 docs/tutorials/wiki2/src/authentication/tutorial/templates/403.jinja2 (limited to 'docs/tutorials') diff --git a/docs/tutorials/wiki2/authentication.rst b/docs/tutorials/wiki2/authentication.rst index 3f2fcec83..580e4ba75 100644 --- a/docs/tutorials/wiki2/authentication.rst +++ b/docs/tutorials/wiki2/authentication.rst @@ -10,7 +10,7 @@ APIs to add login and logout functionality to our wiki. We will implement authentication with the following steps: -* Add an :term:`authentication policy` and a ``request.user`` computed property +* Add a :term:`security policy` and a ``request.user`` computed property (``security.py``). * Add routes for ``/login`` and ``/logout`` (``routes.py``). * Add login and logout views (``views/auth.py``). @@ -18,25 +18,24 @@ We will implement authentication with the following steps: * Add "Login" and "Logout" links to every page based on the user's authenticated state (``layout.jinja2``). * Make the existing views verify user state (``views/default.py``). -* Redirect to ``/login`` when a user is denied access to any of the views that - require permission, instead of a default "403 Forbidden" page - (``views/auth.py``). +* Redirect to ``/login`` when a user is not logged in and is denied access to any of the views that require permission (``views/auth.py``).. +* Show a custom "403 Forbidden" page if a logged in user is denied access to any views that require permission (``views/auth.py``). Authenticating requests ----------------------- -The core of :app:`Pyramid` authentication is an :term:`authentication policy` +The core of :app:`Pyramid` authentication is a :term:`security policy` which is used to identify authentication information from a ``request``, as well as handling the low-level login and logout operations required to track users across requests (via cookies, headers, or whatever else you can imagine). -Add the authentication policy -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Add the security policy +~~~~~~~~~~~~~~~~~~~~~~~ -Create a new file ``tutorial/security.py`` with the following content: +Update ``tutorial/security.py`` with the following content: .. literalinclude:: src/authentication/tutorial/security.py :linenos: @@ -44,49 +43,26 @@ Create a new file ``tutorial/security.py`` with the following content: Here we've defined: -* A new authentication policy named ``MyAuthenticationPolicy``, which is - subclassed from Pyramid's - :class:`pyramid.authentication.AuthTktAuthenticationPolicy`, which tracks the - :term:`userid` using a signed cookie (lines 7-11). -* A ``get_user`` function, which can convert the ``unauthenticated_userid`` - from the policy into a ``User`` object from our database (lines 13-17). -* The ``get_user`` is registered on the request as ``request.user`` to be used - throughout our application as the authenticated ``User`` object for the - logged-in user (line 27). - -The logic in this file is a little bit interesting, so we'll go into detail -about what's happening here: - -First, the default authentication policies all provide a method named -``unauthenticated_userid`` which is responsible for the low-level parsing -of the information in the request (cookies, headers, etc.). If a ``userid`` -is found, then it is returned from this method. This is named -``unauthenticated_userid`` because, at the lowest level, it knows the value of -the userid in the cookie, but it doesn't know if it's actually a user in our -system (remember, anything the user sends to our app is untrusted). - -Second, our application should only care about ``authenticated_userid`` and -``request.user``, which have gone through our application-specific process of -validating that the user is logged in. - -In order to provide an ``authenticated_userid`` we need a verification step. -That can happen anywhere, so we've elected to do it inside of the cached -``request.user`` computed property. This is a convenience that makes -``request.user`` the source of truth in our system. It is either ``None`` or -a ``User`` object from our database. This is why the ``get_user`` function -uses the ``unauthenticated_userid`` to check the database. +* A new security policy named ``MySecurityPolicy``, which is implementing most of the :class:`pyramid.interfaces.ISecurityPolicy` interface by tracking a :term:`identity` using a signed cookie implemented by :class:`pyramid.authentication.AuthTktCookieHelper` (lines 7-29). +* The ``request.user`` computed property is registered for use throughout our application as the authenticated ``tutorial.models.User`` object for the logged-in user (line 38-39). +Our new :term:`security policy` defines how our application will remember, forget, and identify users. +It also handles authorization, which we'll cover in the next chapter (if you're wondering why we didn't implement the ``permits`` method yet). -Configure the app -~~~~~~~~~~~~~~~~~ +Identifying the current user is done in a couple steps: -Since we've added a new ``tutorial/security.py`` module, we need to include it. -Open the file ``tutorial/__init__.py`` and edit the following lines: +1. The ``MySecurityPolicy.authenticated_identity`` method asks the cookie helper to pull the identity from the request. + This value is ``None`` if the cookie is missing or the content cannot be verified. +2. We then translate the identity into a ``tutorial.models.User`` object by looking for a record in the database. + +This is a good spot to confirm that the user is actually allowed to access our application. +For example, maybe they were marked deleted or banned and we should return ``None`` instead of the ``user`` object. + +Finally, :attr:`pyramid.request.Request.authenticated_identity` contains either ``None`` or a ``tutorial.models.User`` instance and that value is aliased to ``request.user`` for convenience in our application. -.. literalinclude:: src/authentication/tutorial/__init__.py - :linenos: - :emphasize-lines: 11 - :language: python + +Configure the app +~~~~~~~~~~~~~~~~~ Our authentication policy is expecting a new setting, ``auth.secret``. Open the file ``development.ini`` and add the highlighted line below: @@ -97,7 +73,7 @@ the file ``development.ini`` and add the highlighted line below: :lineno-match: :language: ini -Finally, best practices tell us to use a different secret for production, so +Finally, best practices tell us to use a different secret in each environment, so open ``production.ini`` and add a different secret: .. literalinclude:: src/authentication/production.ini @@ -106,6 +82,14 @@ open ``production.ini`` and add a different secret: :lineno-match: :language: ini +And ``testing.ini``: + +.. literalinclude:: src/authentication/testing.ini + :lines: 17-19 + :emphasize-lines: 3 + :lineno-match: + :language: ini + Add permission checks ~~~~~~~~~~~~~~~~~~~~~ @@ -125,7 +109,7 @@ Remember our goals: Open the file ``tutorial/views/default.py`` and fix the following import: .. literalinclude:: src/authentication/tutorial/views/default.py - :lines: 5-9 + :lines: 3-7 :lineno-match: :emphasize-lines: 2 :language: python @@ -135,7 +119,7 @@ Change the highlighted line. In the same file, now edit the ``edit_page`` view function: .. literalinclude:: src/authentication/tutorial/views/default.py - :lines: 45-60 + :lines: 44-59 :lineno-match: :emphasize-lines: 5-7 :language: python @@ -148,18 +132,16 @@ If the user either is not logged in or the user is not the page's creator In the same file, now edit the ``add_page`` view function: .. literalinclude:: src/authentication/tutorial/views/default.py - :lines: 62-76 + :lines: 61- :lineno-match: :emphasize-lines: 3-5,13 :language: python Only the highlighted lines need to be changed. -If the user either is not logged in or is not in the ``basic`` or ``editor`` -roles, then we raise ``HTTPForbidden``, which will return a "403 Forbidden" -response to the user. However, we will hook this later to redirect to the login -page. Also, now that we have ``request.user``, we no longer have to hard-code -the creator as the ``editor`` user, so we can finally drop that hack. +If the user either is not logged in or is not in the ``basic`` or ``editor`` roles, then we raise ``HTTPForbidden``, which will trigger our forbidden view to compute a response. +However, we will hook this later to redirect to the login page. +Also, now that we have ``request.user``, we no longer have to hard-code the creator as the ``editor`` user, so we can finally drop that hack. These simple checks should protect our views. @@ -215,6 +197,9 @@ This code adds three new views to the application: The check is done by first finding a ``User`` record in the database, then using our ``user.check_password`` method to compare the hashed passwords. + At a privilege boundary we are sure to reset the CSRF token using :meth:`pyramid.csrf.new_csrf_token`. + If we were using sessions we would want to invalidate that as well. + If the credentials are valid, then we use our authentication policy to store the user's id in the response using :meth:`pyramid.security.remember`. @@ -227,16 +212,19 @@ This code adds three new views to the application: credentials using :meth:`pyramid.security.forget`, then redirecting them to the front page. + At a privilege boundary we are sure to reset the CSRF token using :meth:`pyramid.csrf.new_csrf_token`. + If we were using sessions we would want to invalidate that as well. + - The ``forbidden_view`` is registered using the :class:`pyramid.view.forbidden_view_config` decorator. This is a special :term:`exception view`, which is invoked when a :class:`pyramid.httpexceptions.HTTPForbidden` exception is raised. - This view will handle a forbidden error by redirecting the user to - ``/login``. As a convenience, it also sets the ``next=`` query string to the - current URL (the one that is forbidding access). This way, if the user - successfully logs in, they will be sent back to the page which they had been - trying to access. + By default, the view will return a "403 Forbidden" response and display our ``403.jinja2`` template (added below). + + However, if the user is not logged in, this view will handle a forbidden error by redirecting the user to ``/login``. + As a convenience, it also sets the ``next=`` query string to the current URL (the one that is forbidding access). + This way, if the user successfully logs in, they will be sent back to the page which they had been trying to access. Add the ``login.jinja2`` template @@ -258,9 +246,9 @@ Open ``tutorial/templates/layout.jinja2`` and add the following code as indicated by the highlighted lines. .. literalinclude:: src/authentication/tutorial/templates/layout.jinja2 - :lines: 35-46 + :lines: 35-48 :lineno-match: - :emphasize-lines: 2-10 + :emphasize-lines: 2-12 :language: html The ``request.user`` will be ``None`` if the user is not authenticated, or a @@ -269,6 +257,17 @@ make the logout link shown only when the user is logged in, and conversely the login link is only shown when the user is logged out. +Add the ``403.jinja2`` template +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Create ``tutorial/templates/403.jinja2`` with the following content: + +.. literalinclude:: src/authentication/tutorial/templates/403.jinja2 + :language: html + +The above template is referenced in the forbidden view that we just added in ``tutorial/views/auth.py``. + + Viewing the application in a browser ------------------------------------ @@ -287,15 +286,16 @@ following URLs, checking that the result is as expected: - http://localhost:6543/FrontPage/edit_page invokes the ``edit_page`` view for the ``FrontPage`` page object. It is executable by only the ``editor`` user. - If a different user (or the anonymous user) invokes it, then a login form - will be displayed. Supplying the credentials with the username ``editor`` and - password ``editor`` will display the edit page form. + If a different user invokes it, then the "403 Forbidden" page will be displayed. + If an anonymous user invokes it, then a login form will be displayed. + Supplying the credentials with the username ``editor`` and password ``editor`` will display the edit page form. - http://localhost:6543/add_page/SomePageName invokes the ``add_page`` view for a page. If the page already exists, then it redirects the user to the ``edit_page`` view for the page object. It is executable by either the - ``editor`` or ``basic`` user. If a different user (or the anonymous user) - invokes it, then a login form will be displayed. Supplying the credentials + ``editor`` or ``basic`` user. + If an anonymous user invokes it, then a login form will be displayed. + Supplying the credentials with either the username ``editor`` and password ``editor``, or username ``basic`` and password ``basic``, will display the edit page form. diff --git a/docs/tutorials/wiki2/src/authentication/.gitignore b/docs/tutorials/wiki2/src/authentication/.gitignore index 1853d983c..c612e59f2 100644 --- a/docs/tutorials/wiki2/src/authentication/.gitignore +++ b/docs/tutorials/wiki2/src/authentication/.gitignore @@ -19,3 +19,4 @@ Data.fs* .DS_Store coverage test +*.sqlite diff --git a/docs/tutorials/wiki2/src/authentication/setup.py b/docs/tutorials/wiki2/src/authentication/setup.py index 500c5e599..12eabaff2 100644 --- a/docs/tutorials/wiki2/src/authentication/setup.py +++ b/docs/tutorials/wiki2/src/authentication/setup.py @@ -20,8 +20,8 @@ requires = [ 'pyramid_tm', 'SQLAlchemy', 'transaction', - 'zope.sqlalchemy', 'waitress', + 'zope.sqlalchemy', ] tests_require = [ diff --git a/docs/tutorials/wiki2/src/authentication/testing.ini b/docs/tutorials/wiki2/src/authentication/testing.ini new file mode 100644 index 000000000..07ec6550e --- /dev/null +++ b/docs/tutorials/wiki2/src/authentication/testing.ini @@ -0,0 +1,81 @@ +### +# app configuration +# https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/environment.html +### + +[app:main] +use = egg:tutorial + +pyramid.reload_templates = false +pyramid.debug_authorization = false +pyramid.debug_notfound = false +pyramid.debug_routematch = false +pyramid.default_locale_name = en + +sqlalchemy.url = sqlite:///%(here)s/testing.sqlite + +retry.attempts = 3 + +auth.secret = test-seekrit + +[pshell] +setup = tutorial.pshell.setup + +### +# wsgi server configuration +### + +[alembic] +# path to migration scripts +script_location = tutorial/alembic +file_template = %%(year)d%%(month).2d%%(day).2d_%%(rev)s +# file_template = %%(rev)s_%%(slug)s + +[server:main] +use = egg:waitress#main +listen = *:6543 + +### +# logging configuration +# https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/logging.html +### + +[loggers] +keys = root, tutorial, sqlalchemy, alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_tutorial] +level = WARN +handlers = +qualname = tutorial + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine +# "level = INFO" logs SQL queries. +# "level = DEBUG" logs SQL queries and results. +# "level = WARN" logs neither. (Recommended for production systems.) + +[logger_alembic] +level = WARN +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(asctime)s %(levelname)-5.5s [%(name)s:%(lineno)s][%(threadName)s] %(message)s diff --git a/docs/tutorials/wiki2/src/authentication/tests/conftest.py b/docs/tutorials/wiki2/src/authentication/tests/conftest.py new file mode 100644 index 000000000..2db65f887 --- /dev/null +++ b/docs/tutorials/wiki2/src/authentication/tests/conftest.py @@ -0,0 +1,125 @@ +import alembic +import alembic.config +import alembic.command +import os +from pyramid.paster import get_appsettings +from pyramid.scripting import prepare +from pyramid.testing import DummyRequest +import pytest +import transaction +from webob.cookies import Cookie +import webtest + +from tutorial import main +from tutorial import models +from tutorial.models.meta import Base + + +def pytest_addoption(parser): + parser.addoption('--ini', action='store', metavar='INI_FILE') + +@pytest.fixture(scope='session') +def ini_file(request): + # potentially grab this path from a pytest option + return os.path.abspath(request.config.option.ini or 'testing.ini') + +@pytest.fixture(scope='session') +def app_settings(ini_file): + return get_appsettings(ini_file) + +@pytest.fixture(scope='session') +def dbengine(app_settings, ini_file): + engine = models.get_engine(app_settings) + + alembic_cfg = alembic.config.Config(ini_file) + Base.metadata.drop_all(bind=engine) + alembic.command.stamp(alembic_cfg, None, purge=True) + + # run migrations to initialize the database + # depending on how we want to initialize the database from scratch + # we could alternatively call: + # Base.metadata.create_all(bind=engine) + # alembic.command.stamp(alembic_cfg, "head") + alembic.command.upgrade(alembic_cfg, "head") + + yield engine + + Base.metadata.drop_all(bind=engine) + alembic.command.stamp(alembic_cfg, None, purge=True) + +@pytest.fixture(scope='session') +def app(app_settings, dbengine): + return main({}, dbengine=dbengine, **app_settings) + +@pytest.fixture +def tm(): + tm = transaction.TransactionManager(explicit=True) + tm.begin() + tm.doom() + + yield tm + + tm.abort() + +@pytest.fixture +def dbsession(app, tm): + session_factory = app.registry['dbsession_factory'] + return models.get_tm_session(session_factory, tm) + +@pytest.fixture +def testapp(app, tm, dbsession): + # override request.dbsession and request.tm with our own + # externally-controlled values that are shared across requests but aborted + # at the end + testapp = webtest.TestApp(app, extra_environ={ + 'HTTP_HOST': 'example.com', + 'tm.active': True, + 'tm.manager': tm, + 'app.dbsession': dbsession, + }) + + return testapp + +@pytest.fixture +def app_request(app, tm, dbsession): + """ + A real request. + + This request is almost identical to a real request but it has some + drawbacks in tests as it's harder to mock data and is heavier. + + """ + env = prepare(registry=app.registry) + request = env['request'] + request.host = 'example.com' + + # without this, request.dbsession will be joined to the same transaction + # manager but it will be using a different sqlalchemy.orm.Session using + # a separate database transaction + request.dbsession = dbsession + request.tm = tm + + yield request + env['closer']() + +@pytest.fixture +def dummy_request(app, tm, dbsession): + """ + A lightweight dummy request. + + This request is ultra-lightweight and should be used only when the + request itself is not a large focus in the call-stack. + + It is way easier to mock and control side-effects using this object. + + - It does not have request extensions applied. + - Threadlocals are not properly pushed. + + """ + request = DummyRequest() + request.registry = app.registry + request.host = 'example.com' + request.dbsession = dbsession + request.tm = tm + + return request diff --git a/docs/tutorials/wiki2/src/authentication/tests/test_functional.py b/docs/tutorials/wiki2/src/authentication/tests/test_functional.py new file mode 100644 index 000000000..dbcd8aec7 --- /dev/null +++ b/docs/tutorials/wiki2/src/authentication/tests/test_functional.py @@ -0,0 +1,13 @@ +from tutorial import models + +def test_my_view_success(testapp, dbsession): + model = models.MyModel(name='one', value=55) + dbsession.add(model) + dbsession.flush() + + res = testapp.get('/', status=200) + assert res.body + +def test_notfound(testapp): + res = testapp.get('/badurl', status=404) + assert res.status_code == 404 diff --git a/docs/tutorials/wiki2/src/authentication/tests/test_it.py b/docs/tutorials/wiki2/src/authentication/tests/test_it.py deleted file mode 100644 index ea16534fc..000000000 --- a/docs/tutorials/wiki2/src/authentication/tests/test_it.py +++ /dev/null @@ -1,66 +0,0 @@ -import unittest - -from pyramid import testing - -import transaction - - -def dummy_request(dbsession): - return testing.DummyRequest(dbsession=dbsession) - - -class BaseTest(unittest.TestCase): - def setUp(self): - self.config = testing.setUp(settings={ - 'sqlalchemy.url': 'sqlite:///:memory:' - }) - self.config.include('tutorial.models') - settings = self.config.get_settings() - - from tutorial.models import ( - get_engine, - get_session_factory, - get_tm_session, - ) - - self.engine = get_engine(settings) - session_factory = get_session_factory(self.engine) - - self.session = get_tm_session(session_factory, transaction.manager) - - def init_database(self): - from tutorial.models.meta import Base - Base.metadata.create_all(self.engine) - - def tearDown(self): - from tutorial.models.meta import Base - - testing.tearDown() - transaction.abort() - Base.metadata.drop_all(self.engine) - - -class TestMyViewSuccessCondition(BaseTest): - - def setUp(self): - super(TestMyViewSuccessCondition, self).setUp() - self.init_database() - - from tutorial.models import MyModel - - model = MyModel(name='one', value=55) - self.session.add(model) - - def test_passing_view(self): - from tutorial.views.default import my_view - info = my_view(dummy_request(self.session)) - self.assertEqual(info['one'].name, 'one') - self.assertEqual(info['project'], 'myproj') - - -class TestMyViewFailureCondition(BaseTest): - - def test_failing_view(self): - from tutorial.views.default import my_view - info = my_view(dummy_request(self.session)) - self.assertEqual(info.status_int, 500) diff --git a/docs/tutorials/wiki2/src/authentication/tests/test_views.py b/docs/tutorials/wiki2/src/authentication/tests/test_views.py new file mode 100644 index 000000000..8ae464d03 --- /dev/null +++ b/docs/tutorials/wiki2/src/authentication/tests/test_views.py @@ -0,0 +1,23 @@ +from tutorial import models +from tutorial.views.default import my_view +from tutorial.views.notfound import notfound_view + + +def test_my_view_failure(app_request): + info = my_view(app_request) + assert info.status_int == 500 + +def test_my_view_success(app_request, dbsession): + model = models.MyModel(name='one', value=55) + dbsession.add(model) + dbsession.flush() + + info = my_view(app_request) + assert app_request.response.status_int == 200 + assert info['one'].name == 'one' + assert info['project'] == 'myproj' + +def test_notfound_view(app_request): + info = notfound_view(app_request) + assert app_request.response.status_int == 404 + assert info == {} diff --git a/docs/tutorials/wiki2/src/authentication/tutorial/__init__.py b/docs/tutorials/wiki2/src/authentication/tutorial/__init__.py index ce2e9f12a..81a22c68c 100644 --- a/docs/tutorials/wiki2/src/authentication/tutorial/__init__.py +++ b/docs/tutorials/wiki2/src/authentication/tutorial/__init__.py @@ -5,9 +5,9 @@ def main(global_config, **settings): """ This function returns a Pyramid WSGI application. """ with Configurator(settings=settings) as config: - config.include('.models') config.include('pyramid_jinja2') - config.include('.routes') config.include('.security') + config.include('.routes') + config.include('.models') config.scan() return config.make_wsgi_app() diff --git a/docs/tutorials/wiki2/src/authentication/tutorial/models/__init__.py b/docs/tutorials/wiki2/src/authentication/tutorial/models/__init__.py index a4209a6e9..47d77ef01 100644 --- a/docs/tutorials/wiki2/src/authentication/tutorial/models/__init__.py +++ b/docs/tutorials/wiki2/src/authentication/tutorial/models/__init__.py @@ -66,13 +66,21 @@ def includeme(config): # use pyramid_retry to retry a request when transient exceptions occur config.include('pyramid_retry') - session_factory = get_session_factory(get_engine(settings)) + # hook to share the dbengine fixture in testing + dbengine = settings.get('dbengine') + if not dbengine: + dbengine = get_engine(settings) + + session_factory = get_session_factory(dbengine) config.registry['dbsession_factory'] = session_factory # make request.dbsession available for use in Pyramid - config.add_request_method( - # r.tm is the transaction manager used by pyramid_tm - lambda r: get_tm_session(session_factory, r.tm), - 'dbsession', - reify=True - ) + def dbsession(request): + # hook to share the dbsession fixture in testing + dbsession = request.environ.get('app.dbsession') + if dbsession is None: + # request.tm is the transaction manager used by pyramid_tm + dbsession = get_tm_session(session_factory, request.tm) + return dbsession + + config.add_request_method(dbsession, reify=True) diff --git a/docs/tutorials/wiki2/src/authentication/tutorial/scripts/initialize_db.py b/docs/tutorials/wiki2/src/authentication/tutorial/scripts/initialize_db.py index e6350fb36..c8034e5a5 100644 --- a/docs/tutorials/wiki2/src/authentication/tutorial/scripts/initialize_db.py +++ b/docs/tutorials/wiki2/src/authentication/tutorial/scripts/initialize_db.py @@ -8,6 +8,10 @@ from .. import models def setup_models(dbsession): + """ + Add or update models / fixtures in the database. + + """ editor = models.User(name='editor', role='editor') editor.set_password('editor') dbsession.add(editor) diff --git a/docs/tutorials/wiki2/src/authentication/tutorial/security.py b/docs/tutorials/wiki2/src/authentication/tutorial/security.py index 8ea3858d2..48149d6e5 100644 --- a/docs/tutorials/wiki2/src/authentication/tutorial/security.py +++ b/docs/tutorials/wiki2/src/authentication/tutorial/security.py @@ -1,27 +1,39 @@ -from pyramid.authentication import AuthTktAuthenticationPolicy -from pyramid.authorization import ACLAuthorizationPolicy +from pyramid.authentication import AuthTktCookieHelper +from pyramid.csrf import CookieCSRFStoragePolicy -from .models import User +from . import models -class MyAuthenticationPolicy(AuthTktAuthenticationPolicy): +class MySecurityPolicy: + def __init__(self, secret): + self.authtkt = AuthTktCookieHelper(secret) + + def authenticated_identity(self, request): + identity = self.authtkt.identify(request) + if identity is None: + return None + + userid = identity['userid'] + user = request.dbsession.query(models.User).get(userid) + return user + def authenticated_userid(self, request): - user = request.user + user = self.authenticated_identity(request) if user is not None: return user.id -def get_user(request): - user_id = request.unauthenticated_userid - if user_id is not None: - user = request.dbsession.query(User).get(user_id) - return user + def remember(self, request, userid, **kw): + return self.authtkt.remember(request, userid, **kw) + + def forget(self, request, **kw): + return self.authtkt.forget(request, **kw) def includeme(config): settings = config.get_settings() - authn_policy = MyAuthenticationPolicy( - settings['auth.secret'], - hashalg='sha512', - ) - config.set_authentication_policy(authn_policy) - config.set_authorization_policy(ACLAuthorizationPolicy()) - config.add_request_method(get_user, 'user', reify=True) + + config.set_csrf_storage_policy(CookieCSRFStoragePolicy()) + config.set_default_csrf_options(require_csrf=True) + + config.set_security_policy(MySecurityPolicy(settings['auth.secret'])) + config.add_request_method( + lambda request: request.authenticated_identity, 'user', property=True) diff --git a/docs/tutorials/wiki2/src/authentication/tutorial/templates/403.jinja2 b/docs/tutorials/wiki2/src/authentication/tutorial/templates/403.jinja2 new file mode 100644 index 000000000..7a6f523bc --- /dev/null +++ b/docs/tutorials/wiki2/src/authentication/tutorial/templates/403.jinja2 @@ -0,0 +1,6 @@ +{% extends "layout.jinja2" %} + +{% block content %} +<h1><span class="font-semi-bold">Pyramid</span> <span class="smaller">Starter project</span></h1> +<p class="lead"><span class="font-semi-bold">403</span> Forbidden</p> +{% endblock content %} diff --git a/docs/tutorials/wiki2/src/authentication/tutorial/templates/404.jinja2 b/docs/tutorials/wiki2/src/authentication/tutorial/templates/404.jinja2 index aaf12413f..5edb15285 100644 --- a/docs/tutorials/wiki2/src/authentication/tutorial/templates/404.jinja2 +++ b/docs/tutorials/wiki2/src/authentication/tutorial/templates/404.jinja2 @@ -1,8 +1,6 @@ {% extends "layout.jinja2" %} {% block content %} -<div class="content"> - <h1><span class="font-semi-bold">Pyramid</span> <span class="smaller">Starter project</span></h1> - <p class="lead"><span class="font-semi-bold">404</span> Page Not Found</p> -</div> +<h1><span class="font-semi-bold">Pyramid</span> <span class="smaller">Starter project</span></h1> +<p class="lead"><span class="font-semi-bold">404</span> Page Not Found</p> {% endblock content %} diff --git a/docs/tutorials/wiki2/src/authentication/tutorial/templates/edit.jinja2 b/docs/tutorials/wiki2/src/authentication/tutorial/templates/edit.jinja2 index 7db25c674..27b545054 100644 --- a/docs/tutorials/wiki2/src/authentication/tutorial/templates/edit.jinja2 +++ b/docs/tutorials/wiki2/src/authentication/tutorial/templates/edit.jinja2 @@ -10,11 +10,12 @@ Editing <strong>{{pagename}}</strong> <a href="{{request.route_url('view_page', pagename='FrontPage')}}">FrontPage</a>. </p> <form action="{{ save_url }}" method="post"> +<input type="hidden" name="csrf_token" value="{{ get_csrf_token() }}"> <div class="form-group"> <textarea class="form-control" name="body" rows="10" cols="60">{{ pagedata }}</textarea> </div> <div class="form-group"> - <button type="submit" name="form.submitted" value="Save" class="btn btn-default">Save</button> + <button type="submit" class="btn btn-default">Save</button> </div> </form> {% endblock content %} diff --git a/docs/tutorials/wiki2/src/authentication/tutorial/templates/layout.jinja2 b/docs/tutorials/wiki2/src/authentication/tutorial/templates/layout.jinja2 index 4016b26c9..64a1db0c5 100644 --- a/docs/tutorials/wiki2/src/authentication/tutorial/templates/layout.jinja2 +++ b/docs/tutorials/wiki2/src/authentication/tutorial/templates/layout.jinja2 @@ -35,17 +35,28 @@ <div class="content"> {% if request.user is none %} <p class="pull-right"> - <a href="{{ request.route_url('login') }}">Login</a> + <a href="{{ request.route_url('login') }}">Login</a> </p> {% else %} - <p class="pull-right"> - {{request.user.name}} <a href="{{request.route_url('logout')}}">Logout</a> - </p> + <form class="pull-right" action="{{ request.route_url('logout') }}" method="post"> + {{request.user.name}} + <input type="hidden" name="csrf_token" value="{{ get_csrf_token() }}"> + <button class="btn btn-link" type="submit">Logout</button> + </form> {% endif %} {% block content %}{% endblock %} </div> </div> </div> + <div class="row"> + <div class="links"> + <ul> + <li><i class="glyphicon glyphicon-cog icon-muted"></i><a href="https://github.com/Pylons/pyramid">Github Project</a></li> + <li><i class="glyphicon glyphicon-globe icon-muted"></i><a href="https://webchat.freenode.net/?channels=pyramid">IRC Channel</a></li> + <li><i class="glyphicon glyphicon-home icon-muted"></i><a href="https://pylonsproject.org">Pylons Project</a></li> + </ul> + </div> + </div> <div class="row"> <div class="copyright"> Copyright © Pylons Project diff --git a/docs/tutorials/wiki2/src/authentication/tutorial/templates/login.jinja2 b/docs/tutorials/wiki2/src/authentication/tutorial/templates/login.jinja2 index 1806de0ff..058b7254b 100644 --- a/docs/tutorials/wiki2/src/authentication/tutorial/templates/login.jinja2 +++ b/docs/tutorials/wiki2/src/authentication/tutorial/templates/login.jinja2 @@ -10,6 +10,7 @@ {{ message }} </p> <form action="{{ url }}" method="post"> +<input type="hidden" name="csrf_token" value="{{ get_csrf_token() }}"> <input type="hidden" name="next" value="{{ next_url }}"> <div class="form-group"> <label for="login">Username</label> @@ -20,7 +21,7 @@ <input type="password" name="password"> </div> <div class="form-group"> - <button type="submit" name="form.submitted" value="Log In" class="btn btn-default">Log In</button> + <button type="submit" class="btn btn-default">Log In</button> </div> </form> {% endblock content %} diff --git a/docs/tutorials/wiki2/src/authentication/tutorial/views/auth.py b/docs/tutorials/wiki2/src/authentication/tutorial/views/auth.py index 16fa616e5..e1a564415 100644 --- a/docs/tutorials/wiki2/src/authentication/tutorial/views/auth.py +++ b/docs/tutorials/wiki2/src/authentication/tutorial/views/auth.py @@ -1,14 +1,15 @@ -from pyramid.httpexceptions import HTTPFound +from pyramid.csrf import new_csrf_token +from pyramid.httpexceptions import HTTPSeeOther from pyramid.security import ( remember, forget, - ) +) from pyramid.view import ( forbidden_view_config, view_config, ) -from ..models import User +from .. import models @view_config(route_name='login', renderer='tutorial:templates/login.jinja2') @@ -18,29 +19,43 @@ def login(request): next_url = request.route_url('view_wiki') message = '' login = '' - if 'form.submitted' in request.params: + if request.method == 'POST': login = request.params['login'] password = request.params['password'] - user = request.dbsession.query(User).filter_by(name=login).first() + user = ( + request.dbsession.query(models.User) + .filter_by(name=login) + .first() + ) if user is not None and user.check_password(password): + new_csrf_token(request) headers = remember(request, user.id) - return HTTPFound(location=next_url, headers=headers) + return HTTPSeeOther(location=next_url, headers=headers) message = 'Failed login' + request.response.status = 400 return dict( message=message, url=request.route_url('login'), next_url=next_url, login=login, - ) + ) @view_config(route_name='logout') def logout(request): - headers = forget(request) next_url = request.route_url('view_wiki') - return HTTPFound(location=next_url, headers=headers) + if request.method == 'POST': + new_csrf_token(request) + headers = forget(request) + return HTTPSeeOther(location=next_url, headers=headers) + + return HTTPSeeOther(location=next_url) + +@forbidden_view_config(renderer='tutorial:templates/403.jinja2') +def forbidden_view(exc, request): + if request.user is None: + next_url = request.route_url('login', _query={'next': request.url}) + return HTTPSeeOther(location=next_url) -@forbidden_view_config() -def forbidden_view(request): - next_url = request.route_url('login', _query={'next': request.url}) - return HTTPFound(location=next_url) + request.response.status = 403 + return {} diff --git a/docs/tutorials/wiki2/src/authentication/tutorial/views/default.py b/docs/tutorials/wiki2/src/authentication/tutorial/views/default.py index d1c429950..ebb49ef49 100644 --- a/docs/tutorials/wiki2/src/authentication/tutorial/views/default.py +++ b/docs/tutorials/wiki2/src/authentication/tutorial/views/default.py @@ -1,24 +1,23 @@ from html import escape -import re from docutils.core import publish_parts - from pyramid.httpexceptions import ( HTTPForbidden, - HTTPFound, HTTPNotFound, - ) - + HTTPSeeOther, +) from pyramid.view import view_config +import re from .. import models + # regular expression used to find WikiWords wikiwords = re.compile(r"\b([A-Z]\w+[A-Z]+\w+)") @view_config(route_name='view_wiki') def view_wiki(request): next_url = request.route_url('view_page', pagename='FrontPage') - return HTTPFound(location=next_url) + return HTTPSeeOther(location=next_url) @view_config(route_name='view_page', renderer='tutorial:templates/view.jinja2') def view_page(request): @@ -49,15 +48,15 @@ def edit_page(request): user = request.user if user is None or (user.role != 'editor' and page.creator != user): raise HTTPForbidden - if 'form.submitted' in request.params: + if request.method == 'POST': page.data = request.params['body'] next_url = request.route_url('view_page', pagename=page.name) - return HTTPFound(location=next_url) + return HTTPSeeOther(location=next_url) return dict( pagename=page.name, pagedata=page.data, save_url=request.route_url('edit_page', pagename=page.name), - ) + ) @view_config(route_name='add_page', renderer='tutorial:templates/edit.jinja2') def add_page(request): @@ -67,13 +66,13 @@ def add_page(request): pagename = request.matchdict['pagename'] if request.dbsession.query(models.Page).filter_by(name=pagename).count() > 0: next_url = request.route_url('edit_page', pagename=pagename) - return HTTPFound(location=next_url) - if 'form.submitted' in request.params: + return HTTPSeeOther(location=next_url) + if request.method == 'POST': body = request.params['body'] page = models.Page(name=pagename, data=body) page.creator = request.user request.dbsession.add(page) next_url = request.route_url('view_page', pagename=pagename) - return HTTPFound(location=next_url) + return HTTPSeeOther(location=next_url) save_url = request.route_url('add_page', pagename=pagename) return dict(pagename=pagename, pagedata='', save_url=save_url) -- cgit v1.2.3 From 9629dcfa579e5c78a285e26e42dcff2b1b2df8b7 Mon Sep 17 00:00:00 2001 From: Michael Merickel <michael@merickel.org> Date: Mon, 6 Jan 2020 23:20:37 -0600 Subject: update authorization docs with new security policy --- docs/tutorials/wiki2/authorization.rst | 55 +++------ .../src/authentication/tutorial/views/default.py | 2 +- docs/tutorials/wiki2/src/authorization/.gitignore | 1 + docs/tutorials/wiki2/src/authorization/setup.py | 2 +- docs/tutorials/wiki2/src/authorization/testing.ini | 81 +++++++++++++ .../wiki2/src/authorization/tests/conftest.py | 125 +++++++++++++++++++++ .../src/authorization/tests/test_functional.py | 13 +++ .../wiki2/src/authorization/tests/test_it.py | 66 ----------- .../wiki2/src/authorization/tests/test_views.py | 23 ++++ .../wiki2/src/authorization/tutorial/__init__.py | 4 +- .../src/authorization/tutorial/models/__init__.py | 22 ++-- .../wiki2/src/authorization/tutorial/routes.py | 7 +- .../tutorial/scripts/initialize_db.py | 4 + .../wiki2/src/authorization/tutorial/security.py | 56 +++++---- .../authorization/tutorial/templates/403.jinja2 | 6 + .../authorization/tutorial/templates/404.jinja2 | 6 +- .../authorization/tutorial/templates/edit.jinja2 | 3 +- .../authorization/tutorial/templates/layout.jinja2 | 19 +++- .../authorization/tutorial/templates/login.jinja2 | 3 +- .../wiki2/src/authorization/tutorial/views/auth.py | 41 ++++--- .../src/authorization/tutorial/views/default.py | 20 ++-- .../wiki2/src/views/tutorial/views/default.py | 2 +- 22 files changed, 391 insertions(+), 170 deletions(-) create mode 100644 docs/tutorials/wiki2/src/authorization/testing.ini create mode 100644 docs/tutorials/wiki2/src/authorization/tests/conftest.py create mode 100644 docs/tutorials/wiki2/src/authorization/tests/test_functional.py delete mode 100644 docs/tutorials/wiki2/src/authorization/tests/test_it.py create mode 100644 docs/tutorials/wiki2/src/authorization/tests/test_views.py create mode 100644 docs/tutorials/wiki2/src/authorization/tutorial/templates/403.jinja2 (limited to 'docs/tutorials') diff --git a/docs/tutorials/wiki2/authorization.rst b/docs/tutorials/wiki2/authorization.rst index 234f40e3b..e8f95f8cf 100644 --- a/docs/tutorials/wiki2/authorization.rst +++ b/docs/tutorials/wiki2/authorization.rst @@ -12,10 +12,8 @@ the constraints from the view function itself. We will implement access control with the following steps: -* Update the :term:`authentication policy` to break down the :term:`userid` - into a list of :term:`principals <principal>` (``security.py``). -* Define an :term:`authorization policy` for mapping users, resources and - permissions (``security.py``). +* Update the :term:`security policy` to break down the :term:`identity` into a list of :term:`principals <principal>` (``security.py``). +* Utilize the :class:`pyramid.authorization.ACLHelper` to support a per-context mapping of principals to permissions (``security.py``). * Add new :term:`resource` definitions that will be used as the :term:`context` for the wiki pages (``routes.py``). * Add an :term:`ACL` to each resource (``routes.py``). @@ -23,8 +21,8 @@ We will implement access control with the following steps: (``views/default.py``). -Add user principals -------------------- +Add ACL support +--------------- A :term:`principal` is a level of abstraction on top of the raw :term:`userid` that describes the user in terms of its capabilities, roles, or other @@ -42,7 +40,7 @@ Open the file ``tutorial/security.py`` and edit it as follows: .. literalinclude:: src/authorization/tutorial/security.py :linenos: - :emphasize-lines: 3-6,17-24 + :emphasize-lines: 2,4-7,15,37-48 :language: python Only the highlighted lines need to be added. @@ -51,33 +49,16 @@ Note that the role comes from the ``User`` object. We also add the ``user.id`` as a principal for when we want to allow that exact user to edit pages which they have created. +We're using the :class:`pyramid.authorization.ACLHelper`, which will suffice for most applications. +It uses the :term:`context` to define the mapping between a :term:`principal` and :term:`permission` for the current request via the ``__acl__`` method or attribute. -Add the authorization policy ----------------------------- - -We already added the :term:`authorization policy` in the previous chapter -because :app:`Pyramid` requires one when adding an -:term:`authentication policy`. However, it was not used anywhere, so we'll -mention it now. - -In the file ``tutorial/security.py``, notice the following lines: - -.. literalinclude:: src/authorization/tutorial/security.py - :lines: 38-40 - :lineno-match: - :emphasize-lines: 2 - :language: python - -We're using the :class:`pyramid.authorization.ACLAuthorizationPolicy`, which -will suffice for most applications. It uses the :term:`context` to define the -mapping between a :term:`principal` and :term:`permission` for the current -request via the ``__acl__``. +The ``permits`` method completes our implementation of the :class:`pyramid.interfaces.ISecurityPolicy` interface and enables our application to use :attr:`pyramid.request.Request.has_permission` and the ``permission=`` constraint on views. Add resources and ACLs ---------------------- -Resources are the hidden gem of :app:`Pyramid`. You've made it! +Resources and context are the hidden gems of :app:`Pyramid`. You've made it! Every URL in a web application represents a :term:`resource` (the "R" in Uniform Resource Locator). Often the resource is something in your data model, @@ -108,7 +89,7 @@ Open the file ``tutorial/routes.py`` and edit the following lines: .. literalinclude:: src/authorization/tutorial/routes.py :linenos: - :emphasize-lines: 1-11,17- + :emphasize-lines: 1-11,18- :language: python The highlighted lines need to be edited or added. @@ -120,7 +101,7 @@ the principals of either ``role:editor`` or ``role:basic`` to have the ``create`` permission: .. literalinclude:: src/authorization/tutorial/routes.py - :lines: 30-38 + :lines: 31-39 :lineno-match: :emphasize-lines: 5-9 :language: python @@ -129,7 +110,7 @@ The ``NewPage`` is loaded as the :term:`context` of the ``add_page`` route by declaring a ``factory`` on the route: .. literalinclude:: src/authorization/tutorial/routes.py - :lines: 18-19 + :lines: 19-20 :lineno-match: :emphasize-lines: 1-2 :language: python @@ -138,7 +119,7 @@ The ``PageResource`` class defines the :term:`ACL` for a ``Page``. It uses an actual ``Page`` object to determine *who* can do *what* to the page. .. literalinclude:: src/authorization/tutorial/routes.py - :lines: 47- + :lines: 48- :lineno-match: :emphasize-lines: 5-10 :language: python @@ -147,7 +128,7 @@ The ``PageResource`` is loaded as the :term:`context` of the ``view_page`` and ``edit_page`` routes by declaring a ``factory`` on the routes: .. literalinclude:: src/authorization/tutorial/routes.py - :lines: 17-21 + :lines: 18-22 :lineno-match: :emphasize-lines: 1,4-5 :language: python @@ -167,7 +148,7 @@ Open the file ``tutorial/views/default.py``. First, you can drop a few imports that are no longer necessary: .. literalinclude:: src/authorization/tutorial/views/default.py - :lines: 5-7 + :lines: 3-5 :lineno-match: :emphasize-lines: 1 :language: python @@ -207,7 +188,7 @@ Note the ``pagename`` here is pulled off of the context instead of ``request.matchdict``. The factory has done a lot of work for us to hide the actual route pattern. -The ACLs defined on each :term:`resource` are used by the :term:`authorization +The ACLs defined on each :term:`resource` are used by the :term:`security policy` to determine if any :term:`principal` is allowed to have some :term:`permission`. If this check fails (for example, the user is not logged in) then an ``HTTPForbidden`` exception will be raised automatically. Thus @@ -238,14 +219,14 @@ following URLs, checking that the result is as expected: - http://localhost:6543/FrontPage/edit_page invokes the ``edit_page`` view for the ``FrontPage`` page object. It is executable by only the ``editor`` user. - If a different user (or the anonymous user) invokes it, then a login form + If an anonymous user invokes it, then a login form will be displayed. Supplying the credentials with the username ``editor`` and password ``editor`` will display the edit page form. - http://localhost:6543/add_page/SomePageName invokes the ``add_page`` view for a page. If the page already exists, then it redirects the user to the ``edit_page`` view for the page object. It is executable by either the - ``editor`` or ``basic`` user. If a different user (or the anonymous user) + ``editor`` or ``basic`` user. If an anonymous user invokes it, then a login form will be displayed. Supplying the credentials with either the username ``editor`` and password ``editor``, or username ``basic`` and password ``basic``, will display the edit page form. diff --git a/docs/tutorials/wiki2/src/authentication/tutorial/views/default.py b/docs/tutorials/wiki2/src/authentication/tutorial/views/default.py index ebb49ef49..378ce0ae9 100644 --- a/docs/tutorials/wiki2/src/authentication/tutorial/views/default.py +++ b/docs/tutorials/wiki2/src/authentication/tutorial/views/default.py @@ -1,5 +1,5 @@ -from html import escape from docutils.core import publish_parts +from html import escape from pyramid.httpexceptions import ( HTTPForbidden, HTTPNotFound, diff --git a/docs/tutorials/wiki2/src/authorization/.gitignore b/docs/tutorials/wiki2/src/authorization/.gitignore index 1853d983c..c612e59f2 100644 --- a/docs/tutorials/wiki2/src/authorization/.gitignore +++ b/docs/tutorials/wiki2/src/authorization/.gitignore @@ -19,3 +19,4 @@ Data.fs* .DS_Store coverage test +*.sqlite diff --git a/docs/tutorials/wiki2/src/authorization/setup.py b/docs/tutorials/wiki2/src/authorization/setup.py index 500c5e599..12eabaff2 100644 --- a/docs/tutorials/wiki2/src/authorization/setup.py +++ b/docs/tutorials/wiki2/src/authorization/setup.py @@ -20,8 +20,8 @@ requires = [ 'pyramid_tm', 'SQLAlchemy', 'transaction', - 'zope.sqlalchemy', 'waitress', + 'zope.sqlalchemy', ] tests_require = [ diff --git a/docs/tutorials/wiki2/src/authorization/testing.ini b/docs/tutorials/wiki2/src/authorization/testing.ini new file mode 100644 index 000000000..07ec6550e --- /dev/null +++ b/docs/tutorials/wiki2/src/authorization/testing.ini @@ -0,0 +1,81 @@ +### +# app configuration +# https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/environment.html +### + +[app:main] +use = egg:tutorial + +pyramid.reload_templates = false +pyramid.debug_authorization = false +pyramid.debug_notfound = false +pyramid.debug_routematch = false +pyramid.default_locale_name = en + +sqlalchemy.url = sqlite:///%(here)s/testing.sqlite + +retry.attempts = 3 + +auth.secret = test-seekrit + +[pshell] +setup = tutorial.pshell.setup + +### +# wsgi server configuration +### + +[alembic] +# path to migration scripts +script_location = tutorial/alembic +file_template = %%(year)d%%(month).2d%%(day).2d_%%(rev)s +# file_template = %%(rev)s_%%(slug)s + +[server:main] +use = egg:waitress#main +listen = *:6543 + +### +# logging configuration +# https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/logging.html +### + +[loggers] +keys = root, tutorial, sqlalchemy, alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_tutorial] +level = WARN +handlers = +qualname = tutorial + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine +# "level = INFO" logs SQL queries. +# "level = DEBUG" logs SQL queries and results. +# "level = WARN" logs neither. (Recommended for production systems.) + +[logger_alembic] +level = WARN +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(asctime)s %(levelname)-5.5s [%(name)s:%(lineno)s][%(threadName)s] %(message)s diff --git a/docs/tutorials/wiki2/src/authorization/tests/conftest.py b/docs/tutorials/wiki2/src/authorization/tests/conftest.py new file mode 100644 index 000000000..2db65f887 --- /dev/null +++ b/docs/tutorials/wiki2/src/authorization/tests/conftest.py @@ -0,0 +1,125 @@ +import alembic +import alembic.config +import alembic.command +import os +from pyramid.paster import get_appsettings +from pyramid.scripting import prepare +from pyramid.testing import DummyRequest +import pytest +import transaction +from webob.cookies import Cookie +import webtest + +from tutorial import main +from tutorial import models +from tutorial.models.meta import Base + + +def pytest_addoption(parser): + parser.addoption('--ini', action='store', metavar='INI_FILE') + +@pytest.fixture(scope='session') +def ini_file(request): + # potentially grab this path from a pytest option + return os.path.abspath(request.config.option.ini or 'testing.ini') + +@pytest.fixture(scope='session') +def app_settings(ini_file): + return get_appsettings(ini_file) + +@pytest.fixture(scope='session') +def dbengine(app_settings, ini_file): + engine = models.get_engine(app_settings) + + alembic_cfg = alembic.config.Config(ini_file) + Base.metadata.drop_all(bind=engine) + alembic.command.stamp(alembic_cfg, None, purge=True) + + # run migrations to initialize the database + # depending on how we want to initialize the database from scratch + # we could alternatively call: + # Base.metadata.create_all(bind=engine) + # alembic.command.stamp(alembic_cfg, "head") + alembic.command.upgrade(alembic_cfg, "head") + + yield engine + + Base.metadata.drop_all(bind=engine) + alembic.command.stamp(alembic_cfg, None, purge=True) + +@pytest.fixture(scope='session') +def app(app_settings, dbengine): + return main({}, dbengine=dbengine, **app_settings) + +@pytest.fixture +def tm(): + tm = transaction.TransactionManager(explicit=True) + tm.begin() + tm.doom() + + yield tm + + tm.abort() + +@pytest.fixture +def dbsession(app, tm): + session_factory = app.registry['dbsession_factory'] + return models.get_tm_session(session_factory, tm) + +@pytest.fixture +def testapp(app, tm, dbsession): + # override request.dbsession and request.tm with our own + # externally-controlled values that are shared across requests but aborted + # at the end + testapp = webtest.TestApp(app, extra_environ={ + 'HTTP_HOST': 'example.com', + 'tm.active': True, + 'tm.manager': tm, + 'app.dbsession': dbsession, + }) + + return testapp + +@pytest.fixture +def app_request(app, tm, dbsession): + """ + A real request. + + This request is almost identical to a real request but it has some + drawbacks in tests as it's harder to mock data and is heavier. + + """ + env = prepare(registry=app.registry) + request = env['request'] + request.host = 'example.com' + + # without this, request.dbsession will be joined to the same transaction + # manager but it will be using a different sqlalchemy.orm.Session using + # a separate database transaction + request.dbsession = dbsession + request.tm = tm + + yield request + env['closer']() + +@pytest.fixture +def dummy_request(app, tm, dbsession): + """ + A lightweight dummy request. + + This request is ultra-lightweight and should be used only when the + request itself is not a large focus in the call-stack. + + It is way easier to mock and control side-effects using this object. + + - It does not have request extensions applied. + - Threadlocals are not properly pushed. + + """ + request = DummyRequest() + request.registry = app.registry + request.host = 'example.com' + request.dbsession = dbsession + request.tm = tm + + return request diff --git a/docs/tutorials/wiki2/src/authorization/tests/test_functional.py b/docs/tutorials/wiki2/src/authorization/tests/test_functional.py new file mode 100644 index 000000000..dbcd8aec7 --- /dev/null +++ b/docs/tutorials/wiki2/src/authorization/tests/test_functional.py @@ -0,0 +1,13 @@ +from tutorial import models + +def test_my_view_success(testapp, dbsession): + model = models.MyModel(name='one', value=55) + dbsession.add(model) + dbsession.flush() + + res = testapp.get('/', status=200) + assert res.body + +def test_notfound(testapp): + res = testapp.get('/badurl', status=404) + assert res.status_code == 404 diff --git a/docs/tutorials/wiki2/src/authorization/tests/test_it.py b/docs/tutorials/wiki2/src/authorization/tests/test_it.py deleted file mode 100644 index ea16534fc..000000000 --- a/docs/tutorials/wiki2/src/authorization/tests/test_it.py +++ /dev/null @@ -1,66 +0,0 @@ -import unittest - -from pyramid import testing - -import transaction - - -def dummy_request(dbsession): - return testing.DummyRequest(dbsession=dbsession) - - -class BaseTest(unittest.TestCase): - def setUp(self): - self.config = testing.setUp(settings={ - 'sqlalchemy.url': 'sqlite:///:memory:' - }) - self.config.include('tutorial.models') - settings = self.config.get_settings() - - from tutorial.models import ( - get_engine, - get_session_factory, - get_tm_session, - ) - - self.engine = get_engine(settings) - session_factory = get_session_factory(self.engine) - - self.session = get_tm_session(session_factory, transaction.manager) - - def init_database(self): - from tutorial.models.meta import Base - Base.metadata.create_all(self.engine) - - def tearDown(self): - from tutorial.models.meta import Base - - testing.tearDown() - transaction.abort() - Base.metadata.drop_all(self.engine) - - -class TestMyViewSuccessCondition(BaseTest): - - def setUp(self): - super(TestMyViewSuccessCondition, self).setUp() - self.init_database() - - from tutorial.models import MyModel - - model = MyModel(name='one', value=55) - self.session.add(model) - - def test_passing_view(self): - from tutorial.views.default import my_view - info = my_view(dummy_request(self.session)) - self.assertEqual(info['one'].name, 'one') - self.assertEqual(info['project'], 'myproj') - - -class TestMyViewFailureCondition(BaseTest): - - def test_failing_view(self): - from tutorial.views.default import my_view - info = my_view(dummy_request(self.session)) - self.assertEqual(info.status_int, 500) diff --git a/docs/tutorials/wiki2/src/authorization/tests/test_views.py b/docs/tutorials/wiki2/src/authorization/tests/test_views.py new file mode 100644 index 000000000..8ae464d03 --- /dev/null +++ b/docs/tutorials/wiki2/src/authorization/tests/test_views.py @@ -0,0 +1,23 @@ +from tutorial import models +from tutorial.views.default import my_view +from tutorial.views.notfound import notfound_view + + +def test_my_view_failure(app_request): + info = my_view(app_request) + assert info.status_int == 500 + +def test_my_view_success(app_request, dbsession): + model = models.MyModel(name='one', value=55) + dbsession.add(model) + dbsession.flush() + + info = my_view(app_request) + assert app_request.response.status_int == 200 + assert info['one'].name == 'one' + assert info['project'] == 'myproj' + +def test_notfound_view(app_request): + info = notfound_view(app_request) + assert app_request.response.status_int == 404 + assert info == {} diff --git a/docs/tutorials/wiki2/src/authorization/tutorial/__init__.py b/docs/tutorials/wiki2/src/authorization/tutorial/__init__.py index ce2e9f12a..81a22c68c 100644 --- a/docs/tutorials/wiki2/src/authorization/tutorial/__init__.py +++ b/docs/tutorials/wiki2/src/authorization/tutorial/__init__.py @@ -5,9 +5,9 @@ def main(global_config, **settings): """ This function returns a Pyramid WSGI application. """ with Configurator(settings=settings) as config: - config.include('.models') config.include('pyramid_jinja2') - config.include('.routes') config.include('.security') + config.include('.routes') + config.include('.models') config.scan() return config.make_wsgi_app() diff --git a/docs/tutorials/wiki2/src/authorization/tutorial/models/__init__.py b/docs/tutorials/wiki2/src/authorization/tutorial/models/__init__.py index a4209a6e9..47d77ef01 100644 --- a/docs/tutorials/wiki2/src/authorization/tutorial/models/__init__.py +++ b/docs/tutorials/wiki2/src/authorization/tutorial/models/__init__.py @@ -66,13 +66,21 @@ def includeme(config): # use pyramid_retry to retry a request when transient exceptions occur config.include('pyramid_retry') - session_factory = get_session_factory(get_engine(settings)) + # hook to share the dbengine fixture in testing + dbengine = settings.get('dbengine') + if not dbengine: + dbengine = get_engine(settings) + + session_factory = get_session_factory(dbengine) config.registry['dbsession_factory'] = session_factory # make request.dbsession available for use in Pyramid - config.add_request_method( - # r.tm is the transaction manager used by pyramid_tm - lambda r: get_tm_session(session_factory, r.tm), - 'dbsession', - reify=True - ) + def dbsession(request): + # hook to share the dbsession fixture in testing + dbsession = request.environ.get('app.dbsession') + if dbsession is None: + # request.tm is the transaction manager used by pyramid_tm + dbsession = get_tm_session(session_factory, request.tm) + return dbsession + + config.add_request_method(dbsession, reify=True) diff --git a/docs/tutorials/wiki2/src/authorization/tutorial/routes.py b/docs/tutorials/wiki2/src/authorization/tutorial/routes.py index 1fd45a994..f016d7541 100644 --- a/docs/tutorials/wiki2/src/authorization/tutorial/routes.py +++ b/docs/tutorials/wiki2/src/authorization/tutorial/routes.py @@ -1,6 +1,6 @@ from pyramid.httpexceptions import ( HTTPNotFound, - HTTPFound, + HTTPSeeOther, ) from pyramid.security import ( Allow, @@ -9,6 +9,7 @@ from pyramid.security import ( from . import models + def includeme(config): config.add_static_view('static', 'static', cache_max_age=3600) config.add_route('view_wiki', '/') @@ -24,7 +25,7 @@ def new_page_factory(request): pagename = request.matchdict['pagename'] if request.dbsession.query(models.Page).filter_by(name=pagename).count() > 0: next_url = request.route_url('edit_page', pagename=pagename) - raise HTTPFound(location=next_url) + raise HTTPSeeOther(location=next_url) return NewPage(pagename) class NewPage(object): @@ -52,5 +53,5 @@ class PageResource(object): return [ (Allow, Everyone, 'view'), (Allow, 'role:editor', 'edit'), - (Allow, str(self.page.creator_id), 'edit'), + (Allow, 'u:' + str(self.page.creator_id), 'edit'), ] diff --git a/docs/tutorials/wiki2/src/authorization/tutorial/scripts/initialize_db.py b/docs/tutorials/wiki2/src/authorization/tutorial/scripts/initialize_db.py index e6350fb36..c8034e5a5 100644 --- a/docs/tutorials/wiki2/src/authorization/tutorial/scripts/initialize_db.py +++ b/docs/tutorials/wiki2/src/authorization/tutorial/scripts/initialize_db.py @@ -8,6 +8,10 @@ from .. import models def setup_models(dbsession): + """ + Add or update models / fixtures in the database. + + """ editor = models.User(name='editor', role='editor') editor.set_password('editor') dbsession.add(editor) diff --git a/docs/tutorials/wiki2/src/authorization/tutorial/security.py b/docs/tutorials/wiki2/src/authorization/tutorial/security.py index 1ce1c8753..448183c95 100644 --- a/docs/tutorials/wiki2/src/authorization/tutorial/security.py +++ b/docs/tutorials/wiki2/src/authorization/tutorial/security.py @@ -1,5 +1,6 @@ -from pyramid.authentication import AuthTktAuthenticationPolicy -from pyramid.authorization import ACLAuthorizationPolicy +from pyramid.authentication import AuthTktCookieHelper +from pyramid.authorization import ACLHelper +from pyramid.csrf import CookieCSRFStoragePolicy from pyramid.security import ( Authenticated, Everyone, @@ -8,33 +9,50 @@ from pyramid.security import ( from . import models -class MyAuthenticationPolicy(AuthTktAuthenticationPolicy): +class MySecurityPolicy: + def __init__(self, secret): + self.authtkt = AuthTktCookieHelper(secret) + self.acl = ACLHelper() + + def authenticated_identity(self, request): + identity = self.authtkt.identify(request) + if identity is None: + return None + + userid = identity['userid'] + user = request.dbsession.query(models.User).get(userid) + return user + def authenticated_userid(self, request): - user = request.user + user = self.authenticated_identity(request) if user is not None: return user.id + def remember(self, request, userid, **kw): + return self.authtkt.remember(request, userid, **kw) + + def forget(self, request, **kw): + return self.authtkt.forget(request, **kw) + + def permits(self, request, context, permission): + principals = self.effective_principals(request) + return self.acl.permits(context, principals, permission) + def effective_principals(self, request): principals = [Everyone] - user = request.user + user = self.authenticated_identity(request) if user is not None: principals.append(Authenticated) - principals.append(str(user.id)) + principals.append('u:' + str(user.id)) principals.append('role:' + user.role) return principals -def get_user(request): - user_id = request.unauthenticated_userid - if user_id is not None: - user = request.dbsession.query(models.User).get(user_id) - return user - def includeme(config): settings = config.get_settings() - authn_policy = MyAuthenticationPolicy( - settings['auth.secret'], - hashalg='sha512', - ) - config.set_authentication_policy(authn_policy) - config.set_authorization_policy(ACLAuthorizationPolicy()) - config.add_request_method(get_user, 'user', reify=True) + + config.set_csrf_storage_policy(CookieCSRFStoragePolicy()) + config.set_default_csrf_options(require_csrf=True) + + config.set_security_policy(MySecurityPolicy(settings['auth.secret'])) + config.add_request_method( + lambda request: request.authenticated_identity, 'user', property=True) diff --git a/docs/tutorials/wiki2/src/authorization/tutorial/templates/403.jinja2 b/docs/tutorials/wiki2/src/authorization/tutorial/templates/403.jinja2 new file mode 100644 index 000000000..7a6f523bc --- /dev/null +++ b/docs/tutorials/wiki2/src/authorization/tutorial/templates/403.jinja2 @@ -0,0 +1,6 @@ +{% extends "layout.jinja2" %} + +{% block content %} +<h1><span class="font-semi-bold">Pyramid</span> <span class="smaller">Starter project</span></h1> +<p class="lead"><span class="font-semi-bold">403</span> Forbidden</p> +{% endblock content %} diff --git a/docs/tutorials/wiki2/src/authorization/tutorial/templates/404.jinja2 b/docs/tutorials/wiki2/src/authorization/tutorial/templates/404.jinja2 index aaf12413f..5edb15285 100644 --- a/docs/tutorials/wiki2/src/authorization/tutorial/templates/404.jinja2 +++ b/docs/tutorials/wiki2/src/authorization/tutorial/templates/404.jinja2 @@ -1,8 +1,6 @@ {% extends "layout.jinja2" %} {% block content %} -<div class="content"> - <h1><span class="font-semi-bold">Pyramid</span> <span class="smaller">Starter project</span></h1> - <p class="lead"><span class="font-semi-bold">404</span> Page Not Found</p> -</div> +<h1><span class="font-semi-bold">Pyramid</span> <span class="smaller">Starter project</span></h1> +<p class="lead"><span class="font-semi-bold">404</span> Page Not Found</p> {% endblock content %} diff --git a/docs/tutorials/wiki2/src/authorization/tutorial/templates/edit.jinja2 b/docs/tutorials/wiki2/src/authorization/tutorial/templates/edit.jinja2 index 7db25c674..27b545054 100644 --- a/docs/tutorials/wiki2/src/authorization/tutorial/templates/edit.jinja2 +++ b/docs/tutorials/wiki2/src/authorization/tutorial/templates/edit.jinja2 @@ -10,11 +10,12 @@ Editing <strong>{{pagename}}</strong> <a href="{{request.route_url('view_page', pagename='FrontPage')}}">FrontPage</a>. </p> <form action="{{ save_url }}" method="post"> +<input type="hidden" name="csrf_token" value="{{ get_csrf_token() }}"> <div class="form-group"> <textarea class="form-control" name="body" rows="10" cols="60">{{ pagedata }}</textarea> </div> <div class="form-group"> - <button type="submit" name="form.submitted" value="Save" class="btn btn-default">Save</button> + <button type="submit" class="btn btn-default">Save</button> </div> </form> {% endblock content %} diff --git a/docs/tutorials/wiki2/src/authorization/tutorial/templates/layout.jinja2 b/docs/tutorials/wiki2/src/authorization/tutorial/templates/layout.jinja2 index 4016b26c9..64a1db0c5 100644 --- a/docs/tutorials/wiki2/src/authorization/tutorial/templates/layout.jinja2 +++ b/docs/tutorials/wiki2/src/authorization/tutorial/templates/layout.jinja2 @@ -35,17 +35,28 @@ <div class="content"> {% if request.user is none %} <p class="pull-right"> - <a href="{{ request.route_url('login') }}">Login</a> + <a href="{{ request.route_url('login') }}">Login</a> </p> {% else %} - <p class="pull-right"> - {{request.user.name}} <a href="{{request.route_url('logout')}}">Logout</a> - </p> + <form class="pull-right" action="{{ request.route_url('logout') }}" method="post"> + {{request.user.name}} + <input type="hidden" name="csrf_token" value="{{ get_csrf_token() }}"> + <button class="btn btn-link" type="submit">Logout</button> + </form> {% endif %} {% block content %}{% endblock %} </div> </div> </div> + <div class="row"> + <div class="links"> + <ul> + <li><i class="glyphicon glyphicon-cog icon-muted"></i><a href="https://github.com/Pylons/pyramid">Github Project</a></li> + <li><i class="glyphicon glyphicon-globe icon-muted"></i><a href="https://webchat.freenode.net/?channels=pyramid">IRC Channel</a></li> + <li><i class="glyphicon glyphicon-home icon-muted"></i><a href="https://pylonsproject.org">Pylons Project</a></li> + </ul> + </div> + </div> <div class="row"> <div class="copyright"> Copyright © Pylons Project diff --git a/docs/tutorials/wiki2/src/authorization/tutorial/templates/login.jinja2 b/docs/tutorials/wiki2/src/authorization/tutorial/templates/login.jinja2 index 1806de0ff..058b7254b 100644 --- a/docs/tutorials/wiki2/src/authorization/tutorial/templates/login.jinja2 +++ b/docs/tutorials/wiki2/src/authorization/tutorial/templates/login.jinja2 @@ -10,6 +10,7 @@ {{ message }} </p> <form action="{{ url }}" method="post"> +<input type="hidden" name="csrf_token" value="{{ get_csrf_token() }}"> <input type="hidden" name="next" value="{{ next_url }}"> <div class="form-group"> <label for="login">Username</label> @@ -20,7 +21,7 @@ <input type="password" name="password"> </div> <div class="form-group"> - <button type="submit" name="form.submitted" value="Log In" class="btn btn-default">Log In</button> + <button type="submit" class="btn btn-default">Log In</button> </div> </form> {% endblock content %} diff --git a/docs/tutorials/wiki2/src/authorization/tutorial/views/auth.py b/docs/tutorials/wiki2/src/authorization/tutorial/views/auth.py index 16fa616e5..e1a564415 100644 --- a/docs/tutorials/wiki2/src/authorization/tutorial/views/auth.py +++ b/docs/tutorials/wiki2/src/authorization/tutorial/views/auth.py @@ -1,14 +1,15 @@ -from pyramid.httpexceptions import HTTPFound +from pyramid.csrf import new_csrf_token +from pyramid.httpexceptions import HTTPSeeOther from pyramid.security import ( remember, forget, - ) +) from pyramid.view import ( forbidden_view_config, view_config, ) -from ..models import User +from .. import models @view_config(route_name='login', renderer='tutorial:templates/login.jinja2') @@ -18,29 +19,43 @@ def login(request): next_url = request.route_url('view_wiki') message = '' login = '' - if 'form.submitted' in request.params: + if request.method == 'POST': login = request.params['login'] password = request.params['password'] - user = request.dbsession.query(User).filter_by(name=login).first() + user = ( + request.dbsession.query(models.User) + .filter_by(name=login) + .first() + ) if user is not None and user.check_password(password): + new_csrf_token(request) headers = remember(request, user.id) - return HTTPFound(location=next_url, headers=headers) + return HTTPSeeOther(location=next_url, headers=headers) message = 'Failed login' + request.response.status = 400 return dict( message=message, url=request.route_url('login'), next_url=next_url, login=login, - ) + ) @view_config(route_name='logout') def logout(request): - headers = forget(request) next_url = request.route_url('view_wiki') - return HTTPFound(location=next_url, headers=headers) + if request.method == 'POST': + new_csrf_token(request) + headers = forget(request) + return HTTPSeeOther(location=next_url, headers=headers) + + return HTTPSeeOther(location=next_url) + +@forbidden_view_config(renderer='tutorial:templates/403.jinja2') +def forbidden_view(exc, request): + if request.user is None: + next_url = request.route_url('login', _query={'next': request.url}) + return HTTPSeeOther(location=next_url) -@forbidden_view_config() -def forbidden_view(request): - next_url = request.route_url('login', _query={'next': request.url}) - return HTTPFound(location=next_url) + request.response.status = 403 + return {} diff --git a/docs/tutorials/wiki2/src/authorization/tutorial/views/default.py b/docs/tutorials/wiki2/src/authorization/tutorial/views/default.py index de0bcd816..214788357 100644 --- a/docs/tutorials/wiki2/src/authorization/tutorial/views/default.py +++ b/docs/tutorials/wiki2/src/authorization/tutorial/views/default.py @@ -1,19 +1,19 @@ -from html import escape -import re from docutils.core import publish_parts - -from pyramid.httpexceptions import HTTPFound +from html import escape +from pyramid.httpexceptions import HTTPSeeOther from pyramid.view import view_config +import re from .. import models + # regular expression used to find WikiWords wikiwords = re.compile(r"\b([A-Z]\w+[A-Z]+\w+)") @view_config(route_name='view_wiki') def view_wiki(request): next_url = request.route_url('view_page', pagename='FrontPage') - return HTTPFound(location=next_url) + return HTTPSeeOther(location=next_url) @view_config(route_name='view_page', renderer='tutorial:templates/view.jinja2', permission='view') @@ -39,26 +39,26 @@ def view_page(request): permission='edit') def edit_page(request): page = request.context.page - if 'form.submitted' in request.params: + if request.method == 'POST': page.data = request.params['body'] next_url = request.route_url('view_page', pagename=page.name) - return HTTPFound(location=next_url) + return HTTPSeeOther(location=next_url) return dict( pagename=page.name, pagedata=page.data, save_url=request.route_url('edit_page', pagename=page.name), - ) + ) @view_config(route_name='add_page', renderer='tutorial:templates/edit.jinja2', permission='create') def add_page(request): pagename = request.context.pagename - if 'form.submitted' in request.params: + if request.method == 'POST': body = request.params['body'] page = models.Page(name=pagename, data=body) page.creator = request.user request.dbsession.add(page) next_url = request.route_url('view_page', pagename=pagename) - return HTTPFound(location=next_url) + return HTTPSeeOther(location=next_url) save_url = request.route_url('add_page', pagename=pagename) return dict(pagename=pagename, pagedata='', save_url=save_url) diff --git a/docs/tutorials/wiki2/src/views/tutorial/views/default.py b/docs/tutorials/wiki2/src/views/tutorial/views/default.py index ab6f571ca..df0e4cb9e 100644 --- a/docs/tutorials/wiki2/src/views/tutorial/views/default.py +++ b/docs/tutorials/wiki2/src/views/tutorial/views/default.py @@ -1,5 +1,5 @@ -from html import escape from docutils.core import publish_parts +from html import escape from pyramid.httpexceptions import ( HTTPNotFound, HTTPSeeOther, -- cgit v1.2.3 From 3c06a69e753e3e0cda8d1c9a6a1db9c55f7843ea Mon Sep 17 00:00:00 2001 From: Michael Merickel <michael@merickel.org> Date: Tue, 7 Jan 2020 01:12:58 -0600 Subject: revamp the test suite and explain the fixtures --- docs/tutorials/wiki2/basiclayout.rst | 6 + .../tutorials/wiki2/src/authentication/testing.ini | 6 +- docs/tutorials/wiki2/src/authorization/testing.ini | 6 +- docs/tutorials/wiki2/src/basiclayout/testing.ini | 6 +- docs/tutorials/wiki2/src/installation/testing.ini | 6 +- docs/tutorials/wiki2/src/models/testing.ini | 6 +- docs/tutorials/wiki2/src/tests/.gitignore | 1 + docs/tutorials/wiki2/src/tests/setup.py | 2 +- docs/tutorials/wiki2/src/tests/testing.ini | 81 +++++++ docs/tutorials/wiki2/src/tests/tests/conftest.py | 165 +++++++++++++ .../wiki2/src/tests/tests/test_functional.py | 259 ++++++++++----------- .../tutorials/wiki2/src/tests/tests/test_initdb.py | 10 - .../wiki2/src/tests/tests/test_security.py | 23 -- .../wiki2/src/tests/tests/test_user_model.py | 78 ++----- docs/tutorials/wiki2/src/tests/tests/test_views.py | 195 ++++++---------- .../tutorials/wiki2/src/tests/tutorial/__init__.py | 4 +- .../wiki2/src/tests/tutorial/models/__init__.py | 22 +- docs/tutorials/wiki2/src/tests/tutorial/routes.py | 7 +- .../src/tests/tutorial/scripts/initialize_db.py | 4 + .../tutorials/wiki2/src/tests/tutorial/security.py | 56 +++-- .../wiki2/src/tests/tutorial/templates/403.jinja2 | 6 + .../wiki2/src/tests/tutorial/templates/404.jinja2 | 6 +- .../wiki2/src/tests/tutorial/templates/edit.jinja2 | 3 +- .../src/tests/tutorial/templates/layout.jinja2 | 19 +- .../src/tests/tutorial/templates/login.jinja2 | 3 +- .../wiki2/src/tests/tutorial/views/auth.py | 41 ++-- .../wiki2/src/tests/tutorial/views/default.py | 20 +- docs/tutorials/wiki2/src/views/testing.ini | 6 +- docs/tutorials/wiki2/tests.rst | 141 +++++++---- 29 files changed, 700 insertions(+), 488 deletions(-) create mode 100644 docs/tutorials/wiki2/src/tests/testing.ini create mode 100644 docs/tutorials/wiki2/src/tests/tests/conftest.py delete mode 100644 docs/tutorials/wiki2/src/tests/tests/test_initdb.py delete mode 100644 docs/tutorials/wiki2/src/tests/tests/test_security.py create mode 100644 docs/tutorials/wiki2/src/tests/tutorial/templates/403.jinja2 (limited to 'docs/tutorials') diff --git a/docs/tutorials/wiki2/basiclayout.rst b/docs/tutorials/wiki2/basiclayout.rst index e8bc4c5a9..ef78e052b 100644 --- a/docs/tutorials/wiki2/basiclayout.rst +++ b/docs/tutorials/wiki2/basiclayout.rst @@ -348,3 +348,9 @@ code in our stock application. The ``Index`` import and the ``Index`` object creation in ``mymodel.py`` is not required for this tutorial, and will be removed in the next step. + +Tests +----- + +The project contains a basic structure for a test suite using ``pytest``. +The structure is covered later in :ref:`wiki2_adding_tests`. diff --git a/docs/tutorials/wiki2/src/authentication/testing.ini b/docs/tutorials/wiki2/src/authentication/testing.ini index 07ec6550e..d3c601f16 100644 --- a/docs/tutorials/wiki2/src/authentication/testing.ini +++ b/docs/tutorials/wiki2/src/authentication/testing.ini @@ -33,7 +33,7 @@ file_template = %%(year)d%%(month).2d%%(day).2d_%%(rev)s [server:main] use = egg:waitress#main -listen = *:6543 +listen = localhost:6543 ### # logging configuration @@ -50,11 +50,11 @@ keys = console keys = generic [logger_root] -level = WARN +level = INFO handlers = console [logger_tutorial] -level = WARN +level = DEBUG handlers = qualname = tutorial diff --git a/docs/tutorials/wiki2/src/authorization/testing.ini b/docs/tutorials/wiki2/src/authorization/testing.ini index 07ec6550e..d3c601f16 100644 --- a/docs/tutorials/wiki2/src/authorization/testing.ini +++ b/docs/tutorials/wiki2/src/authorization/testing.ini @@ -33,7 +33,7 @@ file_template = %%(year)d%%(month).2d%%(day).2d_%%(rev)s [server:main] use = egg:waitress#main -listen = *:6543 +listen = localhost:6543 ### # logging configuration @@ -50,11 +50,11 @@ keys = console keys = generic [logger_root] -level = WARN +level = INFO handlers = console [logger_tutorial] -level = WARN +level = DEBUG handlers = qualname = tutorial diff --git a/docs/tutorials/wiki2/src/basiclayout/testing.ini b/docs/tutorials/wiki2/src/basiclayout/testing.ini index 85e5e1ae9..5caa1a8dc 100644 --- a/docs/tutorials/wiki2/src/basiclayout/testing.ini +++ b/docs/tutorials/wiki2/src/basiclayout/testing.ini @@ -31,7 +31,7 @@ file_template = %%(year)d%%(month).2d%%(day).2d_%%(rev)s [server:main] use = egg:waitress#main -listen = *:6543 +listen = localhost:6543 ### # logging configuration @@ -48,11 +48,11 @@ keys = console keys = generic [logger_root] -level = WARN +level = INFO handlers = console [logger_tutorial] -level = WARN +level = DEBUG handlers = qualname = tutorial diff --git a/docs/tutorials/wiki2/src/installation/testing.ini b/docs/tutorials/wiki2/src/installation/testing.ini index 85e5e1ae9..5caa1a8dc 100644 --- a/docs/tutorials/wiki2/src/installation/testing.ini +++ b/docs/tutorials/wiki2/src/installation/testing.ini @@ -31,7 +31,7 @@ file_template = %%(year)d%%(month).2d%%(day).2d_%%(rev)s [server:main] use = egg:waitress#main -listen = *:6543 +listen = localhost:6543 ### # logging configuration @@ -48,11 +48,11 @@ keys = console keys = generic [logger_root] -level = WARN +level = INFO handlers = console [logger_tutorial] -level = WARN +level = DEBUG handlers = qualname = tutorial diff --git a/docs/tutorials/wiki2/src/models/testing.ini b/docs/tutorials/wiki2/src/models/testing.ini index 85e5e1ae9..5caa1a8dc 100644 --- a/docs/tutorials/wiki2/src/models/testing.ini +++ b/docs/tutorials/wiki2/src/models/testing.ini @@ -31,7 +31,7 @@ file_template = %%(year)d%%(month).2d%%(day).2d_%%(rev)s [server:main] use = egg:waitress#main -listen = *:6543 +listen = localhost:6543 ### # logging configuration @@ -48,11 +48,11 @@ keys = console keys = generic [logger_root] -level = WARN +level = INFO handlers = console [logger_tutorial] -level = WARN +level = DEBUG handlers = qualname = tutorial diff --git a/docs/tutorials/wiki2/src/tests/.gitignore b/docs/tutorials/wiki2/src/tests/.gitignore index 1853d983c..c612e59f2 100644 --- a/docs/tutorials/wiki2/src/tests/.gitignore +++ b/docs/tutorials/wiki2/src/tests/.gitignore @@ -19,3 +19,4 @@ Data.fs* .DS_Store coverage test +*.sqlite diff --git a/docs/tutorials/wiki2/src/tests/setup.py b/docs/tutorials/wiki2/src/tests/setup.py index 500c5e599..12eabaff2 100644 --- a/docs/tutorials/wiki2/src/tests/setup.py +++ b/docs/tutorials/wiki2/src/tests/setup.py @@ -20,8 +20,8 @@ requires = [ 'pyramid_tm', 'SQLAlchemy', 'transaction', - 'zope.sqlalchemy', 'waitress', + 'zope.sqlalchemy', ] tests_require = [ diff --git a/docs/tutorials/wiki2/src/tests/testing.ini b/docs/tutorials/wiki2/src/tests/testing.ini new file mode 100644 index 000000000..d3c601f16 --- /dev/null +++ b/docs/tutorials/wiki2/src/tests/testing.ini @@ -0,0 +1,81 @@ +### +# app configuration +# https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/environment.html +### + +[app:main] +use = egg:tutorial + +pyramid.reload_templates = false +pyramid.debug_authorization = false +pyramid.debug_notfound = false +pyramid.debug_routematch = false +pyramid.default_locale_name = en + +sqlalchemy.url = sqlite:///%(here)s/testing.sqlite + +retry.attempts = 3 + +auth.secret = test-seekrit + +[pshell] +setup = tutorial.pshell.setup + +### +# wsgi server configuration +### + +[alembic] +# path to migration scripts +script_location = tutorial/alembic +file_template = %%(year)d%%(month).2d%%(day).2d_%%(rev)s +# file_template = %%(rev)s_%%(slug)s + +[server:main] +use = egg:waitress#main +listen = localhost:6543 + +### +# logging configuration +# https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/logging.html +### + +[loggers] +keys = root, tutorial, sqlalchemy, alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = INFO +handlers = console + +[logger_tutorial] +level = DEBUG +handlers = +qualname = tutorial + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine +# "level = INFO" logs SQL queries. +# "level = DEBUG" logs SQL queries and results. +# "level = WARN" logs neither. (Recommended for production systems.) + +[logger_alembic] +level = WARN +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(asctime)s %(levelname)-5.5s [%(name)s:%(lineno)s][%(threadName)s] %(message)s diff --git a/docs/tutorials/wiki2/src/tests/tests/conftest.py b/docs/tutorials/wiki2/src/tests/tests/conftest.py new file mode 100644 index 000000000..094bc06f1 --- /dev/null +++ b/docs/tutorials/wiki2/src/tests/tests/conftest.py @@ -0,0 +1,165 @@ +import alembic +import alembic.config +import alembic.command +import os +from pyramid.paster import get_appsettings +from pyramid.scripting import prepare +from pyramid.testing import DummyRequest +import pytest +import transaction +from webob.cookies import Cookie +import webtest + +from tutorial import main +from tutorial import models +from tutorial.models.meta import Base + + +def pytest_addoption(parser): + parser.addoption('--ini', action='store', metavar='INI_FILE') + +@pytest.fixture(scope='session') +def ini_file(request): + # potentially grab this path from a pytest option + return os.path.abspath(request.config.option.ini or 'testing.ini') + +@pytest.fixture(scope='session') +def app_settings(ini_file): + return get_appsettings(ini_file) + +@pytest.fixture(scope='session') +def dbengine(app_settings, ini_file): + engine = models.get_engine(app_settings) + + alembic_cfg = alembic.config.Config(ini_file) + Base.metadata.drop_all(bind=engine) + alembic.command.stamp(alembic_cfg, None, purge=True) + + # run migrations to initialize the database + # depending on how we want to initialize the database from scratch + # we could alternatively call: + # Base.metadata.create_all(bind=engine) + alembic.command.upgrade(alembic_cfg, "head") + + yield engine + + Base.metadata.drop_all(bind=engine) + alembic.command.stamp(alembic_cfg, None, purge=True) + +@pytest.fixture(scope='session') +def app(app_settings, dbengine): + return main({}, dbengine=dbengine, **app_settings) + +@pytest.fixture +def tm(): + tm = transaction.TransactionManager(explicit=True) + tm.begin() + tm.doom() + + yield tm + + tm.abort() + +@pytest.fixture +def dbsession(app, tm): + session_factory = app.registry['dbsession_factory'] + return models.get_tm_session(session_factory, tm) + +class TestApp(webtest.TestApp): + def get_cookie(self, name, default=None): + # webtest currently doesn't expose the unescaped cookie values + # so we're using webob to parse them for us + # see https://github.com/Pylons/webtest/issues/171 + cookie = Cookie(' '.join( + '%s=%s' % (c.name, c.value) + for c in self.cookiejar + if c.name == name + )) + return next( + (m.value.decode('latin-1') for m in cookie.values()), + default, + ) + + def get_csrf_token(self): + """ + Convenience method to get the current CSRF token. + + This value must be passed to POST/PUT/DELETE requests in either the + "X-CSRF-Token" header or the "csrf_token" form value. + + testapp.post(..., headers={'X-CSRF-Token': testapp.get_csrf_token()}) + + or + + testapp.post(..., {'csrf_token': testapp.get_csrf_token()}) + + """ + return self.get_cookie('csrf_token') + + def login(self, params, status=303, **kw): + """ Convenience method to login the client.""" + body = dict(csrf_token=self.get_csrf_token()) + body.update(params) + return self.post('/login', body, **kw) + +@pytest.fixture +def testapp(app, tm, dbsession): + # override request.dbsession and request.tm with our own + # externally-controlled values that are shared across requests but aborted + # at the end + testapp = TestApp(app, extra_environ={ + 'HTTP_HOST': 'example.com', + 'tm.active': True, + 'tm.manager': tm, + 'app.dbsession': dbsession, + }) + + # initialize a csrf token instead of running an initial request to get one + # from the actual app - this only works using the CookieCSRFStoragePolicy + testapp.set_cookie('csrf_token', 'dummy_csrf_token') + + return testapp + +@pytest.fixture +def app_request(app, tm, dbsession): + """ + A real request. + + This request is almost identical to a real request but it has some + drawbacks in tests as it's harder to mock data and is heavier. + + """ + env = prepare(registry=app.registry) + request = env['request'] + request.host = 'example.com' + + # without this, request.dbsession will be joined to the same transaction + # manager but it will be using a different sqlalchemy.orm.Session using + # a separate database transaction + request.dbsession = dbsession + request.tm = tm + + yield request + env['closer']() + +@pytest.fixture +def dummy_request(app, tm, dbsession): + """ + A lightweight dummy request. + + This request is ultra-lightweight and should be used only when the + request itself is not a large focus in the call-stack. + + It is way easier to mock and control side-effects using this object. + + - It does not have request extensions applied. + - Threadlocals are not properly pushed. + + """ + request = DummyRequest() + request.registry = app.registry + request.host = 'example.com' + request.dbsession = dbsession + request.tm = tm + + return request diff --git a/docs/tutorials/wiki2/src/tests/tests/test_functional.py b/docs/tutorials/wiki2/src/tests/tests/test_functional.py index 0250e71c9..c6bbd3d5a 100644 --- a/docs/tutorials/wiki2/src/tests/tests/test_functional.py +++ b/docs/tutorials/wiki2/src/tests/tests/test_functional.py @@ -1,134 +1,127 @@ +import pytest import transaction -import unittest -import webtest - - -class FunctionalTests(unittest.TestCase): - - basic_login = ( - '/login?login=basic&password=basic' - '&next=FrontPage&form.submitted=Login') - basic_wrong_login = ( - '/login?login=basic&password=incorrect' - '&next=FrontPage&form.submitted=Login') - basic_login_no_next = ( - '/login?login=basic&password=basic' - '&form.submitted=Login') - editor_login = ( - '/login?login=editor&password=editor' - '&next=FrontPage&form.submitted=Login') - - @classmethod - def setUpClass(cls): - from tutorial.models.meta import Base - from tutorial.models import ( - User, - Page, - get_tm_session, - ) - from tutorial import main - - settings = { - 'sqlalchemy.url': 'sqlite://', - 'auth.secret': 'seekrit', - } - app = main({}, **settings) - cls.testapp = webtest.TestApp(app) - - session_factory = app.registry['dbsession_factory'] - cls.engine = session_factory.kw['bind'] - Base.metadata.create_all(bind=cls.engine) - - with transaction.manager: - dbsession = get_tm_session(session_factory, transaction.manager) - editor = User(name='editor', role='editor') - editor.set_password('editor') - basic = User(name='basic', role='basic') - basic.set_password('basic') - page1 = Page(name='FrontPage', data='This is the front page') - page1.creator = editor - page2 = Page(name='BackPage', data='This is the back page') - page2.creator = basic - dbsession.add_all([basic, editor, page1, page2]) - - @classmethod - def tearDownClass(cls): - from tutorial.models.meta import Base - Base.metadata.drop_all(bind=cls.engine) - - def test_root(self): - res = self.testapp.get('/', status=302) - self.assertEqual(res.location, 'http://localhost/FrontPage') - - def test_FrontPage(self): - res = self.testapp.get('/FrontPage', status=200) - self.assertTrue(b'FrontPage' in res.body) - - def test_unexisting_page(self): - self.testapp.get('/SomePage', status=404) - - def test_successful_log_in(self): - res = self.testapp.get(self.basic_login, status=302) - self.assertEqual(res.location, 'http://localhost/FrontPage') - - def test_successful_log_in_no_next(self): - res = self.testapp.get(self.basic_login_no_next, status=302) - self.assertEqual(res.location, 'http://localhost/') - - def test_failed_log_in(self): - res = self.testapp.get(self.basic_wrong_login, status=200) - self.assertTrue(b'login' in res.body) - - def test_logout_link_present_when_logged_in(self): - self.testapp.get(self.basic_login, status=302) - res = self.testapp.get('/FrontPage', status=200) - self.assertTrue(b'Logout' in res.body) - - def test_logout_link_not_present_after_logged_out(self): - self.testapp.get(self.basic_login, status=302) - self.testapp.get('/FrontPage', status=200) - res = self.testapp.get('/logout', status=302) - self.assertTrue(b'Logout' not in res.body) - - def test_anonymous_user_cannot_edit(self): - res = self.testapp.get('/FrontPage/edit_page', status=302).follow() - self.assertTrue(b'Login' in res.body) - - def test_anonymous_user_cannot_add(self): - res = self.testapp.get('/add_page/NewPage', status=302).follow() - self.assertTrue(b'Login' in res.body) - - def test_basic_user_cannot_edit_front(self): - self.testapp.get(self.basic_login, status=302) - res = self.testapp.get('/FrontPage/edit_page', status=302).follow() - self.assertTrue(b'Login' in res.body) - - def test_basic_user_can_edit_back(self): - self.testapp.get(self.basic_login, status=302) - res = self.testapp.get('/BackPage/edit_page', status=200) - self.assertTrue(b'Editing' in res.body) - - def test_basic_user_can_add(self): - self.testapp.get(self.basic_login, status=302) - res = self.testapp.get('/add_page/NewPage', status=200) - self.assertTrue(b'Editing' in res.body) - - def test_editors_member_user_can_edit(self): - self.testapp.get(self.editor_login, status=302) - res = self.testapp.get('/FrontPage/edit_page', status=200) - self.assertTrue(b'Editing' in res.body) - - def test_editors_member_user_can_add(self): - self.testapp.get(self.editor_login, status=302) - res = self.testapp.get('/add_page/NewPage', status=200) - self.assertTrue(b'Editing' in res.body) - - def test_editors_member_user_can_view(self): - self.testapp.get(self.editor_login, status=302) - res = self.testapp.get('/FrontPage', status=200) - self.assertTrue(b'FrontPage' in res.body) - - def test_redirect_to_edit_for_existing_page(self): - self.testapp.get(self.editor_login, status=302) - res = self.testapp.get('/add_page/FrontPage', status=302) - self.assertTrue(b'FrontPage' in res.body) + +from tutorial import models + + +basic_login = dict(login='basic', password='basic') +editor_login = dict(login='editor', password='editor') + +@pytest.fixture(scope='session', autouse=True) +def dummy_data(app): + """ + Add some dummy data to the database. + + Note that this is a session fixture that commits data to the database. + Think about it similarly to running the ``initialize_db`` script at the + start of the test suite. + + This data should not conflict with any other data added throughout the + test suite or there will be issues - so be careful with this pattern! + + """ + tm = transaction.TransactionManager(explicit=True) + with tm: + dbsession = models.get_tm_session(app.registry['dbsession_factory'], tm) + editor = models.User(name='editor', role='editor') + editor.set_password('editor') + basic = models.User(name='basic', role='basic') + basic.set_password('basic') + page1 = models.Page(name='FrontPage', data='This is the front page') + page1.creator = editor + page2 = models.Page(name='BackPage', data='This is the back page') + page2.creator = basic + dbsession.add_all([basic, editor, page1, page2]) + +def test_root(testapp): + res = testapp.get('/', status=303) + assert res.location == 'http://example.com/FrontPage' + +def test_FrontPage(testapp): + res = testapp.get('/FrontPage', status=200) + assert b'FrontPage' in res.body + +def test_missing_page(testapp): + res = testapp.get('/SomePage', status=404) + assert b'404' in res.body + +def test_successful_log_in(testapp): + params = dict( + **basic_login, + csrf_token=testapp.get_csrf_token(), + ) + res = testapp.post('/login', params, status=303) + assert res.location == 'http://example.com/' + +def test_successful_log_with_next(testapp): + params = dict( + **basic_login, + next='WikiPage', + csrf_token=testapp.get_csrf_token(), + ) + res = testapp.post('/login', params, status=303) + assert res.location == 'http://example.com/WikiPage' + +def test_failed_log_in(testapp): + params = dict( + login='basic', + password='incorrect', + csrf_token=testapp.get_csrf_token(), + ) + res = testapp.post('/login', params, status=400) + assert b'login' in res.body + +def test_logout_link_present_when_logged_in(testapp): + testapp.login(basic_login) + res = testapp.get('/FrontPage', status=200) + assert b'Logout' in res.body + +def test_logout_link_not_present_after_logged_out(testapp): + testapp.login(basic_login) + testapp.get('/FrontPage', status=200) + params = dict(csrf_token=testapp.get_csrf_token()) + res = testapp.post('/logout', params, status=303) + assert b'Logout' not in res.body + +def test_anonymous_user_cannot_edit(testapp): + res = testapp.get('/FrontPage/edit_page', status=303).follow() + assert b'Login' in res.body + +def test_anonymous_user_cannot_add(testapp): + res = testapp.get('/add_page/NewPage', status=303).follow() + assert b'Login' in res.body + +def test_basic_user_cannot_edit_front(testapp): + testapp.login(basic_login) + res = testapp.get('/FrontPage/edit_page', status=403) + assert b'403' in res.body + +def test_basic_user_can_edit_back(testapp): + testapp.login(basic_login) + res = testapp.get('/BackPage/edit_page', status=200) + assert b'Editing' in res.body + +def test_basic_user_can_add(testapp): + testapp.login(basic_login) + res = testapp.get('/add_page/NewPage', status=200) + assert b'Editing' in res.body + +def test_editors_member_user_can_edit(testapp): + testapp.login(editor_login) + res = testapp.get('/FrontPage/edit_page', status=200) + assert b'Editing' in res.body + +def test_editors_member_user_can_add(testapp): + testapp.login(editor_login) + res = testapp.get('/add_page/NewPage', status=200) + assert b'Editing' in res.body + +def test_editors_member_user_can_view(testapp): + testapp.login(editor_login) + res = testapp.get('/FrontPage', status=200) + assert b'FrontPage' in res.body + +def test_redirect_to_edit_for_existing_page(testapp): + testapp.login(editor_login) + res = testapp.get('/add_page/FrontPage', status=303) + assert b'FrontPage' in res.body diff --git a/docs/tutorials/wiki2/src/tests/tests/test_initdb.py b/docs/tutorials/wiki2/src/tests/tests/test_initdb.py deleted file mode 100644 index a66945ccc..000000000 --- a/docs/tutorials/wiki2/src/tests/tests/test_initdb.py +++ /dev/null @@ -1,10 +0,0 @@ -import os -import unittest - - -class TestInitializeDB(unittest.TestCase): - - def test_usage(self): - from tutorial.scripts.initialize_db import main - with self.assertRaises(SystemExit): - main(argv=['foo']) diff --git a/docs/tutorials/wiki2/src/tests/tests/test_security.py b/docs/tutorials/wiki2/src/tests/tests/test_security.py deleted file mode 100644 index 9a1455ef9..000000000 --- a/docs/tutorials/wiki2/src/tests/tests/test_security.py +++ /dev/null @@ -1,23 +0,0 @@ -import unittest -from pyramid.testing import DummyRequest - - -class TestMyAuthenticationPolicy(unittest.TestCase): - - def test_no_user(self): - request = DummyRequest() - request.user = None - - from tutorial.security import MyAuthenticationPolicy - policy = MyAuthenticationPolicy(None) - self.assertEqual(policy.authenticated_userid(request), None) - - def test_authenticated_user(self): - from tutorial.models import User - request = DummyRequest() - request.user = User() - request.user.id = 'foo' - - from tutorial.security import MyAuthenticationPolicy - policy = MyAuthenticationPolicy(None) - self.assertEqual(policy.authenticated_userid(request), 'foo') diff --git a/docs/tutorials/wiki2/src/tests/tests/test_user_model.py b/docs/tutorials/wiki2/src/tests/tests/test_user_model.py index 21904da6b..f91116360 100644 --- a/docs/tutorials/wiki2/src/tests/tests/test_user_model.py +++ b/docs/tutorials/wiki2/src/tests/tests/test_user_model.py @@ -1,67 +1,23 @@ -import unittest -import transaction +from tutorial import models -from pyramid import testing +def test_password_hash_saved(): + user = models.User(name='foo', role='bar') + assert user.password_hash is None -class BaseTest(unittest.TestCase): + user.set_password('secret') + assert user.password_hash is not None - def setUp(self): - from tutorial.models import get_tm_session - self.config = testing.setUp(settings={ - 'sqlalchemy.url': 'sqlite:///:memory:' - }) - self.config.include('tutorial.models') - self.config.include('tutorial.routes') +def test_password_hash_not_set(): + user = models.User(name='foo', role='bar') + assert not user.check_password('secret') - session_factory = self.config.registry['dbsession_factory'] - self.session = get_tm_session(session_factory, transaction.manager) +def test_correct_password(): + user = models.User(name='foo', role='bar') + user.set_password('secret') + assert user.check_password('secret') - self.init_database() - - def init_database(self): - from tutorial.models.meta import Base - session_factory = self.config.registry['dbsession_factory'] - engine = session_factory.kw['bind'] - Base.metadata.create_all(engine) - - def tearDown(self): - testing.tearDown() - transaction.abort() - - def makeUser(self, name, role): - from tutorial.models import User - return User(name=name, role=role) - - -class TestSetPassword(BaseTest): - - def test_password_hash_saved(self): - user = self.makeUser(name='foo', role='bar') - self.assertFalse(user.password_hash) - - user.set_password('secret') - self.assertTrue(user.password_hash) - - -class TestCheckPassword(BaseTest): - - def test_password_hash_not_set(self): - user = self.makeUser(name='foo', role='bar') - self.assertFalse(user.password_hash) - - self.assertFalse(user.check_password('secret')) - - def test_correct_password(self): - user = self.makeUser(name='foo', role='bar') - user.set_password('secret') - self.assertTrue(user.password_hash) - - self.assertTrue(user.check_password('secret')) - - def test_incorrect_password(self): - user = self.makeUser(name='foo', role='bar') - user.set_password('secret') - self.assertTrue(user.password_hash) - - self.assertFalse(user.check_password('incorrect')) +def test_incorrect_password(): + user = models.User(name='foo', role='bar') + user.set_password('secret') + assert not user.check_password('incorrect') diff --git a/docs/tutorials/wiki2/src/tests/tests/test_views.py b/docs/tutorials/wiki2/src/tests/tests/test_views.py index 5c17457dd..1ec2795ab 100644 --- a/docs/tutorials/wiki2/src/tests/tests/test_views.py +++ b/docs/tutorials/wiki2/src/tests/tests/test_views.py @@ -1,168 +1,107 @@ -import unittest -import transaction +from tutorial import models -from pyramid import testing +def makeUser(name, role): + return models.User(name=name, role=role) -def dummy_request(dbsession): - return testing.DummyRequest(dbsession=dbsession) - - -class BaseTest(unittest.TestCase): - def setUp(self): - from tutorial.models import get_tm_session - self.config = testing.setUp(settings={ - 'sqlalchemy.url': 'sqlite:///:memory:' - }) - self.config.include('tutorial.models') - self.config.include('tutorial.routes') - - session_factory = self.config.registry['dbsession_factory'] - self.session = get_tm_session(session_factory, transaction.manager) - - self.init_database() - - def init_database(self): - from tutorial.models.meta import Base - session_factory = self.config.registry['dbsession_factory'] - engine = session_factory.kw['bind'] - Base.metadata.create_all(engine) - - def tearDown(self): - testing.tearDown() - transaction.abort() - - def makeUser(self, name, role, password='dummy'): - from tutorial.models import User - user = User(name=name, role=role) - user.set_password(password) - return user - - def makePage(self, name, data, creator): - from tutorial.models import Page - return Page(name=name, data=data, creator=creator) - - -class ViewWikiTests(unittest.TestCase): - def setUp(self): - self.config = testing.setUp() - self.config.include('tutorial.routes') - - def tearDown(self): - testing.tearDown() +def makePage(name, data, creator): + return models.Page(name=name, data=data, creator=creator) +class Test_view_wiki: def _callFUT(self, request): from tutorial.views.default import view_wiki return view_wiki(request) - def test_it(self): - request = testing.DummyRequest() - response = self._callFUT(request) - self.assertEqual(response.location, 'http://example.com/FrontPage') + def test_it(self, dummy_request): + response = self._callFUT(dummy_request) + assert response.location == 'http://example.com/FrontPage' - -class ViewPageTests(BaseTest): +class Test_view_page: def _callFUT(self, request): from tutorial.views.default import view_page return view_page(request) - def test_it(self): + def test_it(self, dummy_request, dbsession): from tutorial.routes import PageResource # add a page to the db - user = self.makeUser('foo', 'editor') - page = self.makePage('IDoExist', 'Hello CruelWorld IDoExist', user) - self.session.add_all([page, user]) + user = makeUser('foo', 'editor') + page = makePage('IDoExist', 'Hello CruelWorld IDoExist', user) + dbsession.add_all([page, user]) # create a request asking for the page we've created - request = dummy_request(self.session) - request.context = PageResource(page) + dummy_request.context = PageResource(page) # call the view we're testing and check its behavior - info = self._callFUT(request) - self.assertEqual(info['page'], page) - self.assertEqual( - info['content'], + info = self._callFUT(dummy_request) + assert info['page'] is page + assert info['content'] == ( '<div class="document">\n' '<p>Hello <a href="http://example.com/add_page/CruelWorld">' 'CruelWorld</a> ' '<a href="http://example.com/IDoExist">' 'IDoExist</a>' - '</p>\n</div>\n') - self.assertEqual(info['edit_url'], - 'http://example.com/IDoExist/edit_page') - + '</p>\n</div>\n' + ) + assert info['edit_url'] == 'http://example.com/IDoExist/edit_page' -class AddPageTests(BaseTest): +class Test_add_page: def _callFUT(self, request): from tutorial.views.default import add_page return add_page(request) - def test_it_pageexists(self): - from tutorial.models import Page + def test_get(self, dummy_request, dbsession): from tutorial.routes import NewPage - request = testing.DummyRequest({'form.submitted': True, - 'body': 'Hello yo!'}, - dbsession=self.session) - request.user = self.makeUser('foo', 'editor') - request.context = NewPage('AnotherPage') - self._callFUT(request) - pagecount = self.session.query(Page).filter_by(name='AnotherPage').count() - self.assertGreater(pagecount, 0) - - def test_it_notsubmitted(self): - from tutorial.routes import NewPage - request = dummy_request(self.session) - request.user = self.makeUser('foo', 'editor') - request.context = NewPage('AnotherPage') - info = self._callFUT(request) - self.assertEqual(info['pagedata'], '') - self.assertEqual(info['save_url'], - 'http://example.com/add_page/AnotherPage') - - def test_it_submitted(self): - from tutorial.models import Page - from tutorial.routes import NewPage - request = testing.DummyRequest({'form.submitted': True, - 'body': 'Hello yo!'}, - dbsession=self.session) - request.user = self.makeUser('foo', 'editor') - request.context = NewPage('AnotherPage') - self._callFUT(request) - page = self.session.query(Page).filter_by(name='AnotherPage').one() - self.assertEqual(page.data, 'Hello yo!') + dummy_request.user = makeUser('foo', 'editor') + dummy_request.context = NewPage('AnotherPage') + info = self._callFUT(dummy_request) + assert info['pagedata'] == '' + assert info['save_url'] == 'http://example.com/add_page/AnotherPage' + + def test_submit_works(self, dummy_request, dbsession): + from tutorial.routes import NewPage -class EditPageTests(BaseTest): + dummy_request.method = 'POST' + dummy_request.POST['body'] = 'Hello yo!' + dummy_request.context = NewPage('AnotherPage') + dummy_request.user = makeUser('foo', 'editor') + self._callFUT(dummy_request) + page = ( + dbsession.query(models.Page) + .filter_by(name='AnotherPage') + .one() + ) + assert page.data == 'Hello yo!' + +class Test_edit_page: def _callFUT(self, request): from tutorial.views.default import edit_page return edit_page(request) - def makeContext(self, page): + def _makeContext(self, page): from tutorial.routes import PageResource return PageResource(page) - def test_it_notsubmitted(self): - user = self.makeUser('foo', 'editor') - page = self.makePage('abc', 'hello', user) - self.session.add_all([page, user]) - - request = dummy_request(self.session) - request.context = self.makeContext(page) - info = self._callFUT(request) - self.assertEqual(info['pagename'], 'abc') - self.assertEqual(info['save_url'], - 'http://example.com/abc/edit_page') - - def test_it_submitted(self): - user = self.makeUser('foo', 'editor') - page = self.makePage('abc', 'hello', user) - self.session.add_all([page, user]) - - request = testing.DummyRequest({'form.submitted': True, - 'body': 'Hello yo!'}, - dbsession=self.session) - request.context = self.makeContext(page) - response = self._callFUT(request) - self.assertEqual(response.location, 'http://example.com/abc') - self.assertEqual(page.data, 'Hello yo!') + def test_get(self, dummy_request, dbsession): + user = makeUser('foo', 'editor') + page = makePage('abc', 'hello', user) + dbsession.add_all([page, user]) + + dummy_request.context = self._makeContext(page) + info = self._callFUT(dummy_request) + assert info['pagename'] == 'abc' + assert info['save_url'] == 'http://example.com/abc/edit_page' + + def test_submit_works(self, dummy_request, dbsession): + user = makeUser('foo', 'editor') + page = makePage('abc', 'hello', user) + dbsession.add_all([page, user]) + + dummy_request.method = 'POST' + dummy_request.POST['body'] = 'Hello yo!' + dummy_request.user = user + dummy_request.context = self._makeContext(page) + response = self._callFUT(dummy_request) + assert response.location == 'http://example.com/abc' + assert page.data == 'Hello yo!' diff --git a/docs/tutorials/wiki2/src/tests/tutorial/__init__.py b/docs/tutorials/wiki2/src/tests/tutorial/__init__.py index ce2e9f12a..81a22c68c 100644 --- a/docs/tutorials/wiki2/src/tests/tutorial/__init__.py +++ b/docs/tutorials/wiki2/src/tests/tutorial/__init__.py @@ -5,9 +5,9 @@ def main(global_config, **settings): """ This function returns a Pyramid WSGI application. """ with Configurator(settings=settings) as config: - config.include('.models') config.include('pyramid_jinja2') - config.include('.routes') config.include('.security') + config.include('.routes') + config.include('.models') config.scan() return config.make_wsgi_app() diff --git a/docs/tutorials/wiki2/src/tests/tutorial/models/__init__.py b/docs/tutorials/wiki2/src/tests/tutorial/models/__init__.py index a4209a6e9..47d77ef01 100644 --- a/docs/tutorials/wiki2/src/tests/tutorial/models/__init__.py +++ b/docs/tutorials/wiki2/src/tests/tutorial/models/__init__.py @@ -66,13 +66,21 @@ def includeme(config): # use pyramid_retry to retry a request when transient exceptions occur config.include('pyramid_retry') - session_factory = get_session_factory(get_engine(settings)) + # hook to share the dbengine fixture in testing + dbengine = settings.get('dbengine') + if not dbengine: + dbengine = get_engine(settings) + + session_factory = get_session_factory(dbengine) config.registry['dbsession_factory'] = session_factory # make request.dbsession available for use in Pyramid - config.add_request_method( - # r.tm is the transaction manager used by pyramid_tm - lambda r: get_tm_session(session_factory, r.tm), - 'dbsession', - reify=True - ) + def dbsession(request): + # hook to share the dbsession fixture in testing + dbsession = request.environ.get('app.dbsession') + if dbsession is None: + # request.tm is the transaction manager used by pyramid_tm + dbsession = get_tm_session(session_factory, request.tm) + return dbsession + + config.add_request_method(dbsession, reify=True) diff --git a/docs/tutorials/wiki2/src/tests/tutorial/routes.py b/docs/tutorials/wiki2/src/tests/tutorial/routes.py index 1fd45a994..f016d7541 100644 --- a/docs/tutorials/wiki2/src/tests/tutorial/routes.py +++ b/docs/tutorials/wiki2/src/tests/tutorial/routes.py @@ -1,6 +1,6 @@ from pyramid.httpexceptions import ( HTTPNotFound, - HTTPFound, + HTTPSeeOther, ) from pyramid.security import ( Allow, @@ -9,6 +9,7 @@ from pyramid.security import ( from . import models + def includeme(config): config.add_static_view('static', 'static', cache_max_age=3600) config.add_route('view_wiki', '/') @@ -24,7 +25,7 @@ def new_page_factory(request): pagename = request.matchdict['pagename'] if request.dbsession.query(models.Page).filter_by(name=pagename).count() > 0: next_url = request.route_url('edit_page', pagename=pagename) - raise HTTPFound(location=next_url) + raise HTTPSeeOther(location=next_url) return NewPage(pagename) class NewPage(object): @@ -52,5 +53,5 @@ class PageResource(object): return [ (Allow, Everyone, 'view'), (Allow, 'role:editor', 'edit'), - (Allow, str(self.page.creator_id), 'edit'), + (Allow, 'u:' + str(self.page.creator_id), 'edit'), ] diff --git a/docs/tutorials/wiki2/src/tests/tutorial/scripts/initialize_db.py b/docs/tutorials/wiki2/src/tests/tutorial/scripts/initialize_db.py index e6350fb36..c8034e5a5 100644 --- a/docs/tutorials/wiki2/src/tests/tutorial/scripts/initialize_db.py +++ b/docs/tutorials/wiki2/src/tests/tutorial/scripts/initialize_db.py @@ -8,6 +8,10 @@ from .. import models def setup_models(dbsession): + """ + Add or update models / fixtures in the database. + + """ editor = models.User(name='editor', role='editor') editor.set_password('editor') dbsession.add(editor) diff --git a/docs/tutorials/wiki2/src/tests/tutorial/security.py b/docs/tutorials/wiki2/src/tests/tutorial/security.py index 1ce1c8753..448183c95 100644 --- a/docs/tutorials/wiki2/src/tests/tutorial/security.py +++ b/docs/tutorials/wiki2/src/tests/tutorial/security.py @@ -1,5 +1,6 @@ -from pyramid.authentication import AuthTktAuthenticationPolicy -from pyramid.authorization import ACLAuthorizationPolicy +from pyramid.authentication import AuthTktCookieHelper +from pyramid.authorization import ACLHelper +from pyramid.csrf import CookieCSRFStoragePolicy from pyramid.security import ( Authenticated, Everyone, @@ -8,33 +9,50 @@ from pyramid.security import ( from . import models -class MyAuthenticationPolicy(AuthTktAuthenticationPolicy): +class MySecurityPolicy: + def __init__(self, secret): + self.authtkt = AuthTktCookieHelper(secret) + self.acl = ACLHelper() + + def authenticated_identity(self, request): + identity = self.authtkt.identify(request) + if identity is None: + return None + + userid = identity['userid'] + user = request.dbsession.query(models.User).get(userid) + return user + def authenticated_userid(self, request): - user = request.user + user = self.authenticated_identity(request) if user is not None: return user.id + def remember(self, request, userid, **kw): + return self.authtkt.remember(request, userid, **kw) + + def forget(self, request, **kw): + return self.authtkt.forget(request, **kw) + + def permits(self, request, context, permission): + principals = self.effective_principals(request) + return self.acl.permits(context, principals, permission) + def effective_principals(self, request): principals = [Everyone] - user = request.user + user = self.authenticated_identity(request) if user is not None: principals.append(Authenticated) - principals.append(str(user.id)) + principals.append('u:' + str(user.id)) principals.append('role:' + user.role) return principals -def get_user(request): - user_id = request.unauthenticated_userid - if user_id is not None: - user = request.dbsession.query(models.User).get(user_id) - return user - def includeme(config): settings = config.get_settings() - authn_policy = MyAuthenticationPolicy( - settings['auth.secret'], - hashalg='sha512', - ) - config.set_authentication_policy(authn_policy) - config.set_authorization_policy(ACLAuthorizationPolicy()) - config.add_request_method(get_user, 'user', reify=True) + + config.set_csrf_storage_policy(CookieCSRFStoragePolicy()) + config.set_default_csrf_options(require_csrf=True) + + config.set_security_policy(MySecurityPolicy(settings['auth.secret'])) + config.add_request_method( + lambda request: request.authenticated_identity, 'user', property=True) diff --git a/docs/tutorials/wiki2/src/tests/tutorial/templates/403.jinja2 b/docs/tutorials/wiki2/src/tests/tutorial/templates/403.jinja2 new file mode 100644 index 000000000..7a6f523bc --- /dev/null +++ b/docs/tutorials/wiki2/src/tests/tutorial/templates/403.jinja2 @@ -0,0 +1,6 @@ +{% extends "layout.jinja2" %} + +{% block content %} +<h1><span class="font-semi-bold">Pyramid</span> <span class="smaller">Starter project</span></h1> +<p class="lead"><span class="font-semi-bold">403</span> Forbidden</p> +{% endblock content %} diff --git a/docs/tutorials/wiki2/src/tests/tutorial/templates/404.jinja2 b/docs/tutorials/wiki2/src/tests/tutorial/templates/404.jinja2 index aaf12413f..5edb15285 100644 --- a/docs/tutorials/wiki2/src/tests/tutorial/templates/404.jinja2 +++ b/docs/tutorials/wiki2/src/tests/tutorial/templates/404.jinja2 @@ -1,8 +1,6 @@ {% extends "layout.jinja2" %} {% block content %} -<div class="content"> - <h1><span class="font-semi-bold">Pyramid</span> <span class="smaller">Starter project</span></h1> - <p class="lead"><span class="font-semi-bold">404</span> Page Not Found</p> -</div> +<h1><span class="font-semi-bold">Pyramid</span> <span class="smaller">Starter project</span></h1> +<p class="lead"><span class="font-semi-bold">404</span> Page Not Found</p> {% endblock content %} diff --git a/docs/tutorials/wiki2/src/tests/tutorial/templates/edit.jinja2 b/docs/tutorials/wiki2/src/tests/tutorial/templates/edit.jinja2 index 7db25c674..27b545054 100644 --- a/docs/tutorials/wiki2/src/tests/tutorial/templates/edit.jinja2 +++ b/docs/tutorials/wiki2/src/tests/tutorial/templates/edit.jinja2 @@ -10,11 +10,12 @@ Editing <strong>{{pagename}}</strong> <a href="{{request.route_url('view_page', pagename='FrontPage')}}">FrontPage</a>. </p> <form action="{{ save_url }}" method="post"> +<input type="hidden" name="csrf_token" value="{{ get_csrf_token() }}"> <div class="form-group"> <textarea class="form-control" name="body" rows="10" cols="60">{{ pagedata }}</textarea> </div> <div class="form-group"> - <button type="submit" name="form.submitted" value="Save" class="btn btn-default">Save</button> + <button type="submit" class="btn btn-default">Save</button> </div> </form> {% endblock content %} diff --git a/docs/tutorials/wiki2/src/tests/tutorial/templates/layout.jinja2 b/docs/tutorials/wiki2/src/tests/tutorial/templates/layout.jinja2 index 4016b26c9..64a1db0c5 100644 --- a/docs/tutorials/wiki2/src/tests/tutorial/templates/layout.jinja2 +++ b/docs/tutorials/wiki2/src/tests/tutorial/templates/layout.jinja2 @@ -35,17 +35,28 @@ <div class="content"> {% if request.user is none %} <p class="pull-right"> - <a href="{{ request.route_url('login') }}">Login</a> + <a href="{{ request.route_url('login') }}">Login</a> </p> {% else %} - <p class="pull-right"> - {{request.user.name}} <a href="{{request.route_url('logout')}}">Logout</a> - </p> + <form class="pull-right" action="{{ request.route_url('logout') }}" method="post"> + {{request.user.name}} + <input type="hidden" name="csrf_token" value="{{ get_csrf_token() }}"> + <button class="btn btn-link" type="submit">Logout</button> + </form> {% endif %} {% block content %}{% endblock %} </div> </div> </div> + <div class="row"> + <div class="links"> + <ul> + <li><i class="glyphicon glyphicon-cog icon-muted"></i><a href="https://github.com/Pylons/pyramid">Github Project</a></li> + <li><i class="glyphicon glyphicon-globe icon-muted"></i><a href="https://webchat.freenode.net/?channels=pyramid">IRC Channel</a></li> + <li><i class="glyphicon glyphicon-home icon-muted"></i><a href="https://pylonsproject.org">Pylons Project</a></li> + </ul> + </div> + </div> <div class="row"> <div class="copyright"> Copyright © Pylons Project diff --git a/docs/tutorials/wiki2/src/tests/tutorial/templates/login.jinja2 b/docs/tutorials/wiki2/src/tests/tutorial/templates/login.jinja2 index 1806de0ff..058b7254b 100644 --- a/docs/tutorials/wiki2/src/tests/tutorial/templates/login.jinja2 +++ b/docs/tutorials/wiki2/src/tests/tutorial/templates/login.jinja2 @@ -10,6 +10,7 @@ {{ message }} </p> <form action="{{ url }}" method="post"> +<input type="hidden" name="csrf_token" value="{{ get_csrf_token() }}"> <input type="hidden" name="next" value="{{ next_url }}"> <div class="form-group"> <label for="login">Username</label> @@ -20,7 +21,7 @@ <input type="password" name="password"> </div> <div class="form-group"> - <button type="submit" name="form.submitted" value="Log In" class="btn btn-default">Log In</button> + <button type="submit" class="btn btn-default">Log In</button> </div> </form> {% endblock content %} diff --git a/docs/tutorials/wiki2/src/tests/tutorial/views/auth.py b/docs/tutorials/wiki2/src/tests/tutorial/views/auth.py index 16fa616e5..e1a564415 100644 --- a/docs/tutorials/wiki2/src/tests/tutorial/views/auth.py +++ b/docs/tutorials/wiki2/src/tests/tutorial/views/auth.py @@ -1,14 +1,15 @@ -from pyramid.httpexceptions import HTTPFound +from pyramid.csrf import new_csrf_token +from pyramid.httpexceptions import HTTPSeeOther from pyramid.security import ( remember, forget, - ) +) from pyramid.view import ( forbidden_view_config, view_config, ) -from ..models import User +from .. import models @view_config(route_name='login', renderer='tutorial:templates/login.jinja2') @@ -18,29 +19,43 @@ def login(request): next_url = request.route_url('view_wiki') message = '' login = '' - if 'form.submitted' in request.params: + if request.method == 'POST': login = request.params['login'] password = request.params['password'] - user = request.dbsession.query(User).filter_by(name=login).first() + user = ( + request.dbsession.query(models.User) + .filter_by(name=login) + .first() + ) if user is not None and user.check_password(password): + new_csrf_token(request) headers = remember(request, user.id) - return HTTPFound(location=next_url, headers=headers) + return HTTPSeeOther(location=next_url, headers=headers) message = 'Failed login' + request.response.status = 400 return dict( message=message, url=request.route_url('login'), next_url=next_url, login=login, - ) + ) @view_config(route_name='logout') def logout(request): - headers = forget(request) next_url = request.route_url('view_wiki') - return HTTPFound(location=next_url, headers=headers) + if request.method == 'POST': + new_csrf_token(request) + headers = forget(request) + return HTTPSeeOther(location=next_url, headers=headers) + + return HTTPSeeOther(location=next_url) + +@forbidden_view_config(renderer='tutorial:templates/403.jinja2') +def forbidden_view(exc, request): + if request.user is None: + next_url = request.route_url('login', _query={'next': request.url}) + return HTTPSeeOther(location=next_url) -@forbidden_view_config() -def forbidden_view(request): - next_url = request.route_url('login', _query={'next': request.url}) - return HTTPFound(location=next_url) + request.response.status = 403 + return {} diff --git a/docs/tutorials/wiki2/src/tests/tutorial/views/default.py b/docs/tutorials/wiki2/src/tests/tutorial/views/default.py index de0bcd816..214788357 100644 --- a/docs/tutorials/wiki2/src/tests/tutorial/views/default.py +++ b/docs/tutorials/wiki2/src/tests/tutorial/views/default.py @@ -1,19 +1,19 @@ -from html import escape -import re from docutils.core import publish_parts - -from pyramid.httpexceptions import HTTPFound +from html import escape +from pyramid.httpexceptions import HTTPSeeOther from pyramid.view import view_config +import re from .. import models + # regular expression used to find WikiWords wikiwords = re.compile(r"\b([A-Z]\w+[A-Z]+\w+)") @view_config(route_name='view_wiki') def view_wiki(request): next_url = request.route_url('view_page', pagename='FrontPage') - return HTTPFound(location=next_url) + return HTTPSeeOther(location=next_url) @view_config(route_name='view_page', renderer='tutorial:templates/view.jinja2', permission='view') @@ -39,26 +39,26 @@ def view_page(request): permission='edit') def edit_page(request): page = request.context.page - if 'form.submitted' in request.params: + if request.method == 'POST': page.data = request.params['body'] next_url = request.route_url('view_page', pagename=page.name) - return HTTPFound(location=next_url) + return HTTPSeeOther(location=next_url) return dict( pagename=page.name, pagedata=page.data, save_url=request.route_url('edit_page', pagename=page.name), - ) + ) @view_config(route_name='add_page', renderer='tutorial:templates/edit.jinja2', permission='create') def add_page(request): pagename = request.context.pagename - if 'form.submitted' in request.params: + if request.method == 'POST': body = request.params['body'] page = models.Page(name=pagename, data=body) page.creator = request.user request.dbsession.add(page) next_url = request.route_url('view_page', pagename=pagename) - return HTTPFound(location=next_url) + return HTTPSeeOther(location=next_url) save_url = request.route_url('add_page', pagename=pagename) return dict(pagename=pagename, pagedata='', save_url=save_url) diff --git a/docs/tutorials/wiki2/src/views/testing.ini b/docs/tutorials/wiki2/src/views/testing.ini index 85e5e1ae9..5caa1a8dc 100644 --- a/docs/tutorials/wiki2/src/views/testing.ini +++ b/docs/tutorials/wiki2/src/views/testing.ini @@ -31,7 +31,7 @@ file_template = %%(year)d%%(month).2d%%(day).2d_%%(rev)s [server:main] use = egg:waitress#main -listen = *:6543 +listen = localhost:6543 ### # logging configuration @@ -48,11 +48,11 @@ keys = console keys = generic [logger_root] -level = WARN +level = INFO handlers = console [logger_tutorial] -level = WARN +level = DEBUG handlers = qualname = tutorial diff --git a/docs/tutorials/wiki2/tests.rst b/docs/tutorials/wiki2/tests.rst index c7d1a0f31..8a3e79363 100644 --- a/docs/tutorials/wiki2/tests.rst +++ b/docs/tutorials/wiki2/tests.rst @@ -8,101 +8,142 @@ We will now add tests for the models and views as well as a few functional tests in a new ``tests`` package. Tests ensure that an application works, and that it continues to work when changes are made in the future. -The file ``tests/test_it.py`` at the root of our project directory was generated from choosing the ``sqlalchemy`` backend option. + +Test harness +============ + +The project came bootstrapped with some tests and a basic harness. +These are located in the ``tests`` package at the top-level of the project. It is a common practice to put tests into a ``tests`` package alongside the application package, especially as projects grow in size and complexity. -Each module in the test package should contain tests for its corresponding module in our application. -Each corresponding pair of modules should have the same names, except the test module should have the prefix ``test_``. +A useful convention is for each module in the application to contain a corresponding module in the ``tests`` package. +The test module would have the same name with the prefix ``test_``. -Start by deleting ``tests/test_it.py``. +The harness consists of the following setup: -.. warning:: +- ``pytest.ini`` - controls basic ``pytest`` config including where to find the tests. + We have configured ``pytest`` to search for tests in the application package and in the ``tests`` package. - It is very important when refactoring a Python module into a package to be - sure to delete the cache files (``.pyc`` files or ``__pycache__`` folders) - sitting around! Python will prioritize the cache files before traversing - into folders, using the old code, and you will wonder why none of your - changes are working! +- ``.coveragerc`` - controls coverage config. + In our setup, it works with the ``pytest-cov`` plugin that we use via the ``--cov`` options to the ``pytest`` command. +- ``testing.ini`` - a mirror of ``development.ini`` and ``production.ini`` that contains settings used for executing the test suite. + Most importantly, it contains the database connection information used by tests that require the database. -Test the views -============== +- ``tests_require`` in ``setup.py`` - controls the dependencies installed when testing. + When the list is changed, it's necessary to re-run ``$VENV/bin/pip install -e ".[testing]"`` to ensure the new dependencies are installed. -We'll create a new ``tests/test_views.py`` file, adding a ``BaseTest`` class -used as the base for other test classes. Next we'll add tests for each view -function we previously added to our application. We'll add four test classes: -``ViewWikiTests``, ``ViewPageTests``, ``AddPageTests``, and ``EditPageTests``. -These test the ``view_wiki``, ``view_page``, ``add_page``, and ``edit_page`` -views. +- ``tests/conftest.py`` - the core fixtures available throughout our tests. + The fixtures are explained in more detail below. -Functional tests -================ +Session-scoped test fixtures +---------------------------- -We'll test the whole application, covering security aspects that are not tested -in the unit tests, like logging in, logging out, checking that the ``basic`` -user cannot edit pages that it didn't create but the ``editor`` user can, and -so on. +- ``app_settings`` - the settings ``dict`` parsed from the ``testing.ini`` file that would normally be passed by ``pserve`` into your app's ``main`` function. +- ``dbengine`` - initializes the database. + It's important to start each run of the test suite from a known state, and this fixture is responsible for preparing the database appropriately. + This includes deleting any existing tables, running migrations, and potentially even loading some fixture data into the tables for use within the tests. -View the results of all our edits to ``tests`` package -====================================================== +- ``app`` - the :app:`Pyramid` WSGI application, implementing the :class:`pyramid.interfaces.IRouter` interface. + Most commonly this would be used for functional tests. -Create ``tests/test_views.py`` such that it appears as follows: -.. literalinclude:: src/tests/tests/test_views.py - :linenos: - :language: python +Per-test fixtures +----------------- -Create ``tests/test_functional.py`` such that it appears as follows: +- ``tm`` - a :class:`transaction.TransactionManager` object controlling a transaction lifecycle. + Generally other fixtures would join to the ``tm`` fixture to control their lifecycle and ensure they are aborted at the end of the test. -.. literalinclude:: src/tests/tests/test_functional.py - :linenos: - :language: python +- ``dbsession`` - a :class:`sqlalchemy.orm.session.Session` object connected to the database. + The session is scoped to the ``tm`` fixture. + Any changes made will be aborted at the end of the test. -Create ``tests/test_initdb.py`` such that it appears as follows: +- ``testapp`` - a :class:`webtest.TestApp` instance wrapping the ``app`` and is used to sending requests into the application and return full response objects that can be inspected. + The ``testapp`` is able to mutate the request environ such that the ``dbsession`` and ``tm`` fixtures are injected and used by any code that's touching ``request.dbsession`` and ``request.tm``. + The ``testapp`` maintains a cookiejar, so it can be used to share state across requests, as well as the transaction database connection. -.. literalinclude:: src/tests/tests/test_initdb.py - :linenos: - :language: python +- ``app_request`` - a :class:`pyramid.request.Request` object that can be used for more lightweight tests versus the full ``testapp``. + The ``app_request`` can be passed to view functions and other code that need a fully functional request object. + +- ``dummy_request`` - a :class:`pyramid.testing.DummyRequest` object that is very lightweight. + This is a great object to pass to view functions that have minimal side-effects as it'll be fast and simple. + + +Modifying the fixtures +---------------------- -Create ``tests/test_security.py`` such that it appears as follows: +We're going to make a few application-specific changes to the test harness. +It's always good to come up with patterns for things that are done often to avoid lots of boilerplate. -.. literalinclude:: src/tests/tests/test_security.py +- Initialize the cookiejar with a CSRF token. + Remember our application is using :class:`pyramid.csrf.CookieCSRFStoragePolicy`. + +- ``testapp.get_csrf_token()`` - every POST/PUT/DELETE/PATCH request must contain the current CSRF token to prove to our app that the client isn't a third-party. + So we want an easy way to grab the current CSRF token and add it to the request. + +- ``testapp.login(params)`` - many pages are only accessible by logged in users so we want a simple way to login a user at the start of a test. + +Update ``tests/conftest.py`` to look like the following, adding the highlighted lines: + +.. literalinclude:: src/tests/tests/conftest.py :linenos: + :emphasize-lines: 10,68-103,110,117-119 :language: python + +Unit tests +========== + +We can test individual APIs within our codebase to ensure they fulfill the expected contract that the rest of the application expects. +For example, we'll test the password hashing features we added to the ``tutorial.models.User`` object. + Create ``tests/test_user_model.py`` such that it appears as follows: .. literalinclude:: src/tests/tests/test_user_model.py :linenos: :language: python -.. note:: - We're utilizing the excellent WebTest_ package to do functional testing of - the application. This is defined in the ``tests_require`` section of our - ``setup.py``. Any other dependencies needed only for testing purposes can be - added there and will be installed automatically when running - ``setup.py test``. +Integration tests +================= + +We can directly execute the view code, bypassing :app:`Pyramid` and testing just the code that we've written. +These tests use dummy requests that we'll prepare appropriately to set the conditions each view expects. +For example, setting ``request.user``, or adding some dummy data to the session. + +Update ``tests/test_views.py`` such that it appears as follows: + +.. literalinclude:: src/tests/tests/test_views.py + :linenos: + :language: python + + +Functional tests +================ + +We'll test the whole application, covering security aspects that are not tested in the unit and integration tests, like logging in, logging out, checking that the ``basic`` user cannot edit pages that it didn't create but the ``editor`` user can, and so on. + +Update ``tests/test_functional.py`` such that it appears as follows: + +.. literalinclude:: src/tests/tests/test_functional.py + :linenos: + :language: python Running the tests ================= -We can run these tests similarly to how we did in :ref:`running_tests`, but first delete the SQLite database ``tutorial.sqlite``. If you do not delete the database, then you will see an integrity error when running the tests. - On Unix: .. code-block:: bash - rm tutorial.sqlite $VENV/bin/pytest -q On Windows: .. code-block:: doscon - del tutorial.sqlite %VENV%\Scripts\pytest -q The expected result should look like the following: -- cgit v1.2.3 From 425c85fadfe40d016eb3320d424ec3741a47d9c4 Mon Sep 17 00:00:00 2001 From: Michael Merickel <michael@merickel.org> Date: Tue, 7 Jan 2020 10:07:43 -0600 Subject: fix punctuation in docs/tutorials/wiki2/authentication.rst Co-Authored-By: Steve Piercy <web@stevepiercy.com> --- docs/tutorials/wiki2/authentication.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'docs/tutorials') diff --git a/docs/tutorials/wiki2/authentication.rst b/docs/tutorials/wiki2/authentication.rst index 580e4ba75..a4937d93e 100644 --- a/docs/tutorials/wiki2/authentication.rst +++ b/docs/tutorials/wiki2/authentication.rst @@ -18,7 +18,7 @@ We will implement authentication with the following steps: * Add "Login" and "Logout" links to every page based on the user's authenticated state (``layout.jinja2``). * Make the existing views verify user state (``views/default.py``). -* Redirect to ``/login`` when a user is not logged in and is denied access to any of the views that require permission (``views/auth.py``).. +* Redirect to ``/login`` when a user is not logged in and is denied access to any of the views that require permission (``views/auth.py``). * Show a custom "403 Forbidden" page if a logged in user is denied access to any views that require permission (``views/auth.py``). -- cgit v1.2.3 From 14403a3f6d7cb870aefafa6bfa8999f86aa29c86 Mon Sep 17 00:00:00 2001 From: Michael Merickel <michael@merickel.org> Date: Tue, 7 Jan 2020 19:49:12 -0600 Subject: use _makeContext to reduce imports --- docs/tutorials/wiki2/src/tests/tests/test_views.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) (limited to 'docs/tutorials') diff --git a/docs/tutorials/wiki2/src/tests/tests/test_views.py b/docs/tutorials/wiki2/src/tests/tests/test_views.py index 1ec2795ab..007184af8 100644 --- a/docs/tutorials/wiki2/src/tests/tests/test_views.py +++ b/docs/tutorials/wiki2/src/tests/tests/test_views.py @@ -21,16 +21,18 @@ class Test_view_page: from tutorial.views.default import view_page return view_page(request) - def test_it(self, dummy_request, dbsession): + def _makeContext(self, page): from tutorial.routes import PageResource + return PageResource(page) + def test_it(self, dummy_request, dbsession): # add a page to the db user = makeUser('foo', 'editor') page = makePage('IDoExist', 'Hello CruelWorld IDoExist', user) dbsession.add_all([page, user]) # create a request asking for the page we've created - dummy_request.context = PageResource(page) + dummy_request.context = self._makeContext(page) # call the view we're testing and check its behavior info = self._callFUT(dummy_request) @@ -50,21 +52,21 @@ class Test_add_page: from tutorial.views.default import add_page return add_page(request) - def test_get(self, dummy_request, dbsession): + def _makeContext(self, pagename): from tutorial.routes import NewPage + return NewPage(pagename) + def test_get(self, dummy_request, dbsession): dummy_request.user = makeUser('foo', 'editor') - dummy_request.context = NewPage('AnotherPage') + dummy_request.context = self._makeContext('AnotherPage') info = self._callFUT(dummy_request) assert info['pagedata'] == '' assert info['save_url'] == 'http://example.com/add_page/AnotherPage' def test_submit_works(self, dummy_request, dbsession): - from tutorial.routes import NewPage - dummy_request.method = 'POST' dummy_request.POST['body'] = 'Hello yo!' - dummy_request.context = NewPage('AnotherPage') + dummy_request.context = self._makeContext('AnotherPage') dummy_request.user = makeUser('foo', 'editor') self._callFUT(dummy_request) page = ( -- cgit v1.2.3 From 7adc44fa2b4bfa5b4230d8646e734ba262ec1ce2 Mon Sep 17 00:00:00 2001 From: Michael Merickel <michael@merickel.org> Date: Tue, 7 Jan 2020 23:48:51 -0600 Subject: demonstrate an identity_cache --- docs/tutorials/wiki2/authentication.rst | 23 ++++++++++++++++++---- docs/tutorials/wiki2/authorization.rst | 2 +- .../wiki2/src/authentication/tutorial/security.py | 7 ++++++- .../wiki2/src/authorization/tutorial/security.py | 7 ++++++- .../tutorials/wiki2/src/tests/tutorial/security.py | 7 ++++++- 5 files changed, 38 insertions(+), 8 deletions(-) (limited to 'docs/tutorials') diff --git a/docs/tutorials/wiki2/authentication.rst b/docs/tutorials/wiki2/authentication.rst index a4937d93e..381868e71 100644 --- a/docs/tutorials/wiki2/authentication.rst +++ b/docs/tutorials/wiki2/authentication.rst @@ -51,15 +51,30 @@ It also handles authorization, which we'll cover in the next chapter (if you're Identifying the current user is done in a couple steps: -1. The ``MySecurityPolicy.authenticated_identity`` method asks the cookie helper to pull the identity from the request. +1. :app:`Pyramid` invokes a method on the policy requesting identity, userid, or permission to perform an operation. + +1. The policy starts by calling :meth:`pyramid.request.RequestLocalCache.get_or_create` to load the identity. + +1. The ``MySecurityPolicy.load_identity`` method asks the cookie helper to pull the identity from the request. This value is ``None`` if the cookie is missing or the content cannot be verified. -2. We then translate the identity into a ``tutorial.models.User`` object by looking for a record in the database. -This is a good spot to confirm that the user is actually allowed to access our application. -For example, maybe they were marked deleted or banned and we should return ``None`` instead of the ``user`` object. +1. The policy then translates the identity into a ``tutorial.models.User`` object by looking for a record in the database. + This is a good spot to confirm that the user is actually allowed to access our application. + For example, maybe they were marked deleted or banned and we should return ``None`` instead of the ``user`` object. + +1. The result is stored in the ``identity_cache`` which ensures that subsequent invocations return the same identity object for the request. Finally, :attr:`pyramid.request.Request.authenticated_identity` contains either ``None`` or a ``tutorial.models.User`` instance and that value is aliased to ``request.user`` for convenience in our application. +Note the usage of the ``identity_cache`` is optional, but it has several advantages in most scenarios: + +- It improves performance as the identity is necessary for many operations during the lifetime of a request. + +- It provides consistency across method invocations to ensure the identity does not change while processing the request. + +It is up to individual security policies and applications to determine the best approach with respect to caching. +Applications is long-running requests may want to avoid caching the identity, or tracking some extra metadata to re-verify it periodically against the authentication source. + Configure the app ~~~~~~~~~~~~~~~~~ diff --git a/docs/tutorials/wiki2/authorization.rst b/docs/tutorials/wiki2/authorization.rst index e8f95f8cf..001bde935 100644 --- a/docs/tutorials/wiki2/authorization.rst +++ b/docs/tutorials/wiki2/authorization.rst @@ -40,7 +40,7 @@ Open the file ``tutorial/security.py`` and edit it as follows: .. literalinclude:: src/authorization/tutorial/security.py :linenos: - :emphasize-lines: 2,4-7,15,37-48 + :emphasize-lines: 2,5-8,17,42-53 :language: python Only the highlighted lines need to be added. diff --git a/docs/tutorials/wiki2/src/authentication/tutorial/security.py b/docs/tutorials/wiki2/src/authentication/tutorial/security.py index 48149d6e5..1027ddd0a 100644 --- a/docs/tutorials/wiki2/src/authentication/tutorial/security.py +++ b/docs/tutorials/wiki2/src/authentication/tutorial/security.py @@ -1,5 +1,6 @@ from pyramid.authentication import AuthTktCookieHelper from pyramid.csrf import CookieCSRFStoragePolicy +from pyramid.request import RequestLocalCache from . import models @@ -7,8 +8,9 @@ from . import models class MySecurityPolicy: def __init__(self, secret): self.authtkt = AuthTktCookieHelper(secret) + self.identity_cache = RequestLocalCache(self.load_identity) - def authenticated_identity(self, request): + def load_identity(self, request): identity = self.authtkt.identify(request) if identity is None: return None @@ -17,6 +19,9 @@ class MySecurityPolicy: user = request.dbsession.query(models.User).get(userid) return user + def authenticated_identity(self, request): + return self.identity_cache.get_or_create(request) + def authenticated_userid(self, request): user = self.authenticated_identity(request) if user is not None: diff --git a/docs/tutorials/wiki2/src/authorization/tutorial/security.py b/docs/tutorials/wiki2/src/authorization/tutorial/security.py index 448183c95..7a99fb9e9 100644 --- a/docs/tutorials/wiki2/src/authorization/tutorial/security.py +++ b/docs/tutorials/wiki2/src/authorization/tutorial/security.py @@ -1,6 +1,7 @@ from pyramid.authentication import AuthTktCookieHelper from pyramid.authorization import ACLHelper from pyramid.csrf import CookieCSRFStoragePolicy +from pyramid.request import RequestLocalCache from pyramid.security import ( Authenticated, Everyone, @@ -12,9 +13,10 @@ from . import models class MySecurityPolicy: def __init__(self, secret): self.authtkt = AuthTktCookieHelper(secret) + self.identity_cache = RequestLocalCache(self.load_identity) self.acl = ACLHelper() - def authenticated_identity(self, request): + def load_identity(self, request): identity = self.authtkt.identify(request) if identity is None: return None @@ -23,6 +25,9 @@ class MySecurityPolicy: user = request.dbsession.query(models.User).get(userid) return user + def authenticated_identity(self, request): + return self.identity_cache.get_or_create(request) + def authenticated_userid(self, request): user = self.authenticated_identity(request) if user is not None: diff --git a/docs/tutorials/wiki2/src/tests/tutorial/security.py b/docs/tutorials/wiki2/src/tests/tutorial/security.py index 448183c95..7a99fb9e9 100644 --- a/docs/tutorials/wiki2/src/tests/tutorial/security.py +++ b/docs/tutorials/wiki2/src/tests/tutorial/security.py @@ -1,6 +1,7 @@ from pyramid.authentication import AuthTktCookieHelper from pyramid.authorization import ACLHelper from pyramid.csrf import CookieCSRFStoragePolicy +from pyramid.request import RequestLocalCache from pyramid.security import ( Authenticated, Everyone, @@ -12,9 +13,10 @@ from . import models class MySecurityPolicy: def __init__(self, secret): self.authtkt = AuthTktCookieHelper(secret) + self.identity_cache = RequestLocalCache(self.load_identity) self.acl = ACLHelper() - def authenticated_identity(self, request): + def load_identity(self, request): identity = self.authtkt.identify(request) if identity is None: return None @@ -23,6 +25,9 @@ class MySecurityPolicy: user = request.dbsession.query(models.User).get(userid) return user + def authenticated_identity(self, request): + return self.identity_cache.get_or_create(request) + def authenticated_userid(self, request): user = self.authenticated_identity(request) if user is not None: -- cgit v1.2.3 From 095eb560dc17dc591d43144758adaf2e4c780e72 Mon Sep 17 00:00:00 2001 From: Michael Merickel <michael@merickel.org> Date: Fri, 10 Jan 2020 00:50:03 -0600 Subject: sync wiki installation, basiclayout, models and views chapters with new cookiecutter --- docs/tutorials/wiki/basiclayout.rst | 35 ++++++----- docs/tutorials/wiki/definingviews.rst | 30 +++++----- docs/tutorials/wiki/installation.rst | 4 +- docs/tutorials/wiki/src/basiclayout/.gitignore | 1 + docs/tutorials/wiki/src/basiclayout/testing.ini | 60 +++++++++++++++++++ .../wiki/src/basiclayout/tests/conftest.py | 69 ++++++++++++++++++++++ .../wiki/src/basiclayout/tests/test_functional.py | 7 +++ .../wiki/src/basiclayout/tests/test_it.py | 24 -------- .../wiki/src/basiclayout/tests/test_views.py | 13 ++++ .../wiki/src/basiclayout/tutorial/__init__.py | 5 +- docs/tutorials/wiki/src/installation/.gitignore | 1 + docs/tutorials/wiki/src/installation/testing.ini | 60 +++++++++++++++++++ .../wiki/src/installation/tests/conftest.py | 69 ++++++++++++++++++++++ .../wiki/src/installation/tests/test_functional.py | 7 +++ .../wiki/src/installation/tests/test_it.py | 24 -------- .../wiki/src/installation/tests/test_views.py | 13 ++++ .../wiki/src/installation/tutorial/__init__.py | 5 +- docs/tutorials/wiki/src/models/.gitignore | 1 + docs/tutorials/wiki/src/models/testing.ini | 60 +++++++++++++++++++ docs/tutorials/wiki/src/models/tests/conftest.py | 69 ++++++++++++++++++++++ .../wiki/src/models/tests/test_functional.py | 7 +++ docs/tutorials/wiki/src/models/tests/test_it.py | 24 -------- docs/tutorials/wiki/src/models/tests/test_views.py | 13 ++++ .../tutorials/wiki/src/models/tutorial/__init__.py | 5 +- docs/tutorials/wiki/src/views/.gitignore | 1 + docs/tutorials/wiki/src/views/setup.py | 2 +- docs/tutorials/wiki/src/views/testing.ini | 60 +++++++++++++++++++ docs/tutorials/wiki/src/views/tests/conftest.py | 69 ++++++++++++++++++++++ .../wiki/src/views/tests/test_functional.py | 7 +++ docs/tutorials/wiki/src/views/tests/test_it.py | 24 -------- docs/tutorials/wiki/src/views/tests/test_views.py | 13 ++++ docs/tutorials/wiki/src/views/tutorial/__init__.py | 5 +- .../wiki/src/views/tutorial/templates/layout.pt | 12 +++- .../wiki/src/views/tutorial/views/default.py | 13 ++-- docs/tutorials/wiki2/installation.rst | 4 +- 35 files changed, 669 insertions(+), 147 deletions(-) create mode 100644 docs/tutorials/wiki/src/basiclayout/testing.ini create mode 100644 docs/tutorials/wiki/src/basiclayout/tests/conftest.py create mode 100644 docs/tutorials/wiki/src/basiclayout/tests/test_functional.py delete mode 100644 docs/tutorials/wiki/src/basiclayout/tests/test_it.py create mode 100644 docs/tutorials/wiki/src/basiclayout/tests/test_views.py create mode 100644 docs/tutorials/wiki/src/installation/testing.ini create mode 100644 docs/tutorials/wiki/src/installation/tests/conftest.py create mode 100644 docs/tutorials/wiki/src/installation/tests/test_functional.py delete mode 100644 docs/tutorials/wiki/src/installation/tests/test_it.py create mode 100644 docs/tutorials/wiki/src/installation/tests/test_views.py create mode 100644 docs/tutorials/wiki/src/models/testing.ini create mode 100644 docs/tutorials/wiki/src/models/tests/conftest.py create mode 100644 docs/tutorials/wiki/src/models/tests/test_functional.py delete mode 100644 docs/tutorials/wiki/src/models/tests/test_it.py create mode 100644 docs/tutorials/wiki/src/models/tests/test_views.py create mode 100644 docs/tutorials/wiki/src/views/testing.ini create mode 100644 docs/tutorials/wiki/src/views/tests/conftest.py create mode 100644 docs/tutorials/wiki/src/views/tests/test_functional.py delete mode 100644 docs/tutorials/wiki/src/views/tests/test_it.py create mode 100644 docs/tutorials/wiki/src/views/tests/test_views.py (limited to 'docs/tutorials') diff --git a/docs/tutorials/wiki/basiclayout.rst b/docs/tutorials/wiki/basiclayout.rst index 4eb5c4283..c1c762ae4 100644 --- a/docs/tutorials/wiki/basiclayout.rst +++ b/docs/tutorials/wiki/basiclayout.rst @@ -57,7 +57,7 @@ Next in ``main``, construct a :term:`Configurator` object using a context manage See also :term:`Deployment settings`. .. literalinclude:: src/basiclayout/tutorial/__init__.py - :lines: 14 + :lines: 15 :lineno-match: :language: py @@ -65,35 +65,28 @@ See also :term:`Deployment settings`. This will be a dictionary of settings parsed from the ``.ini`` file, which contains deployment-related values, such as ``pyramid.reload_templates``, ``zodbconn.uri``, and so on. -Next include support for ``pyramid_tm``, allowing Pyramid requests to join the active transaction as provided by the `transaction <https://pypi.org/project/transaction/>`_ package. - -.. literalinclude:: src/basiclayout/tutorial/__init__.py - :lines: 15 - :lineno-match: - :language: py - -Next include support for ``pyramid_retry`` to retry a request when transient exceptions occur. +Next include support for the :term:`Chameleon` template rendering bindings, allowing us to use the ``.pt`` templates. .. literalinclude:: src/basiclayout/tutorial/__init__.py :lines: 16 :lineno-match: :language: py -Next include support for ``pyramid_zodbconn``, providing integration between :term:`ZODB` and a Pyramid application. +Next include support for ``pyramid_tm``, allowing Pyramid requests to join the active transaction as provided by the `transaction <https://pypi.org/project/transaction/>`_ package. .. literalinclude:: src/basiclayout/tutorial/__init__.py :lines: 17 :lineno-match: :language: py -Next set a root factory using our function named ``root_factory``. +Next include support for ``pyramid_retry`` to retry a request when transient exceptions occur. .. literalinclude:: src/basiclayout/tutorial/__init__.py :lines: 18 :lineno-match: :language: py -Next include support for the :term:`Chameleon` template rendering bindings, allowing us to use the ``.pt`` templates. +Next include support for ``pyramid_zodbconn``, providing integration between :term:`ZODB` and a Pyramid application. .. literalinclude:: src/basiclayout/tutorial/__init__.py :lines: 19 @@ -107,6 +100,13 @@ Next include routes from the ``.routes`` module. :lineno-match: :language: py +Next set a root factory using our function named ``root_factory``. + +.. literalinclude:: src/basiclayout/tutorial/__init__.py + :lines: 21 + :lineno-match: + :language: py + The included module contains the following function. .. literalinclude:: src/basiclayout/tutorial/routes.py @@ -130,7 +130,7 @@ The third argument is an optional ``cache_max_age`` which specifies the number o Back into our ``__init__.py``, next perform a :term:`scan`. .. literalinclude:: src/basiclayout/tutorial/__init__.py - :lines: 21 + :lines: 22 :lineno-match: :language: py @@ -142,7 +142,7 @@ The cookiecutter could have equivalently said ``config.scan('tutorial')``, but i Finally use the :meth:`pyramid.config.Configurator.make_wsgi_app` method to return a :term:`WSGI` application. .. literalinclude:: src/basiclayout/tutorial/__init__.py - :lines: 22 + :lines: 23 :lineno-match: :language: py @@ -262,3 +262,10 @@ The ``development.ini`` (in the ``tutorial`` :term:`project` directory, as oppos Note the existence of a ``[app:main]`` section which specifies our WSGI application. Our ZODB database settings are specified as the ``zodbconn.uri`` setting within this section. When the server is started via ``pserve``, the values within this section are passed as ``**settings`` to the ``main`` function defined in ``__init__.py``. + + +Tests +----- + +The project contains a basic structure for a test suite using ``pytest``. +The structure is covered later in :ref:`wiki_adding_tests`. diff --git a/docs/tutorials/wiki/definingviews.rst b/docs/tutorials/wiki/definingviews.rst index 2e4d009a1..5aafd68d6 100644 --- a/docs/tutorials/wiki/definingviews.rst +++ b/docs/tutorials/wiki/definingviews.rst @@ -39,8 +39,9 @@ We need to add a dependency on the ``docutils`` package to our ``tutorial`` pack Open ``setup.py`` and edit it to look like the following: .. literalinclude:: src/views/setup.py - :linenos: - :emphasize-lines: 22 + :lines: 11-29 + :lineno-match: + :emphasize-lines: 2 :language: python Only the highlighted line needs to be added. @@ -91,7 +92,7 @@ We added some imports and created a regular expression to find "WikiWords". We got rid of the ``my_view`` view function and its decorator that was added when originally rendered after we selected the ``zodb`` backend option in the cookiecutter. It was only an example and is not relevant to our application. -Then we added four :term:`view callable` functions to our ``views.py`` module: +Then we added four :term:`view callable` functions to our ``default.py`` module: * ``view_wiki()`` - Displays the wiki itself. It will answer on the root URL. * ``view_page()`` - Displays an individual page. @@ -102,7 +103,7 @@ We will describe each one briefly in the following sections. .. note:: - There is nothing special about the filename ``views.py``. + There is nothing special about the filename ``default.py``. A project may have many view callables throughout its codebase in arbitrarily named files. Files that implement view callables often have ``view`` in their names (or may live in a Python subpackage of your application package named ``views``), but this is only by convention. @@ -113,7 +114,7 @@ The ``view_wiki`` view function Following is the code for the ``view_wiki`` view function and its decorator: .. literalinclude:: src/views/tutorial/views/default.py - :lines: 13-15 + :lines: 12-14 :lineno-match: :language: python @@ -133,9 +134,9 @@ The view configuration associated with ``view_wiki`` does not use a ``renderer`` No renderer is necessary when a view returns a response object. The ``view_wiki`` view callable always redirects to the URL of a ``Page`` resource named ``FrontPage``. -To do so, it returns an instance of the :class:`pyramid.httpexceptions.HTTPFound` class. +To do so, it returns an instance of the :class:`pyramid.httpexceptions.HTTPSeeOther` class. Instances of this class implement the :class:`pyramid.interfaces.IResponse` interface, similar to :class:`pyramid.response.Response`. -It uses the :meth:`pyramid.request.Request.route_url` API to construct an URL to the ``FrontPage`` page resource (in other words, ``http://localhost:6543/FrontPage``), and uses it as the ``location`` of the ``HTTPFound`` response, forming an HTTP redirect. +It uses the :meth:`pyramid.request.Request.route_url` API to construct an URL to the ``FrontPage`` page resource (in other words, ``http://localhost:6543/FrontPage``), and uses it as the ``location`` of the ``HTTPSeeOther`` response, forming an HTTP redirect. The ``view_page`` view function @@ -144,7 +145,7 @@ The ``view_page`` view function Here is the code for the ``view_page`` view function and its decorator: .. literalinclude:: src/views/tutorial/views/default.py - :lines: 18-35 + :lines: 17-34 :lineno-match: :language: python @@ -183,7 +184,7 @@ The ``add_page`` view function Here is the code for the ``add_page`` view function and its decorator: .. literalinclude:: src/views/tutorial/views/default.py - :lines: 38-53 + :lines: 37-52 :lineno-match: :language: python @@ -231,7 +232,7 @@ The ``edit_page`` view function Here is the code for the ``edit_page`` view function and its decorator: .. literalinclude:: src/views/tutorial/views/default.py - :lines: 56-64 + :lines: 55- :lineno-match: :language: python @@ -260,7 +261,7 @@ Open ``tutorial/views/notfound.py`` and make the changes shown by the emphasized .. literalinclude:: src/views/tutorial/views/notfound.py :linenos: :language: python - :emphasize-lines: 3-4, 9-12 + :emphasize-lines: 3, 9-12 We need to import the ``Page`` from our models. We eventually return a ``Page`` object as ``page`` into the template ``layout.pt`` to display its name in the title tag. @@ -282,7 +283,7 @@ Update ``tutorial/templates/layout.pt`` with the following content, as indicated .. literalinclude:: src/views/tutorial/templates/layout.pt :linenos: - :emphasize-lines: 11-12, 37-41 + :emphasize-lines: 11, 36-40 :language: html Since we are using a templating engine, we can factor common boilerplate out of our page templates into reusable components. @@ -291,11 +292,10 @@ We can do this via :term:`METAL` macros and slots. - The cookiecutter defined a macro named ``layout`` (line 1). This macro consists of the entire template. - We changed the ``title`` tag to use the ``name`` attribute of a ``page`` object, or if it does not exist then the page title (lines 11-12). -- The cookiecutter defined a macro customization point or `slot` (line 36). +- The cookiecutter defined a macro customization point or `slot` (line 35). This slot is inside the macro ``layout``. Therefore it can be replaced by content, customizing the macro. -- We added a ``div`` element with a link to allow the user to return to the front page (lines 37-41). -- We removed the row of icons and links from the original cookiecutter. +- We added a ``div`` element with a link to allow the user to return to the front page (lines 36-40). .. seealso:: diff --git a/docs/tutorials/wiki/installation.rst b/docs/tutorials/wiki/installation.rst index 6088f577d..392441eae 100644 --- a/docs/tutorials/wiki/installation.rst +++ b/docs/tutorials/wiki/installation.rst @@ -243,8 +243,8 @@ For a successful test run, you should see output that ends like this: .. code-block:: bash - .. - 2 passed in 0.49 seconds + .... + 4 passed in 0.49 seconds Expose test coverage information diff --git a/docs/tutorials/wiki/src/basiclayout/.gitignore b/docs/tutorials/wiki/src/basiclayout/.gitignore index 1853d983c..c612e59f2 100644 --- a/docs/tutorials/wiki/src/basiclayout/.gitignore +++ b/docs/tutorials/wiki/src/basiclayout/.gitignore @@ -19,3 +19,4 @@ Data.fs* .DS_Store coverage test +*.sqlite diff --git a/docs/tutorials/wiki/src/basiclayout/testing.ini b/docs/tutorials/wiki/src/basiclayout/testing.ini new file mode 100644 index 000000000..9298354ac --- /dev/null +++ b/docs/tutorials/wiki/src/basiclayout/testing.ini @@ -0,0 +1,60 @@ +### +# app configuration +# https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/environment.html +### + +[app:main] +use = egg:tutorial + +pyramid.reload_templates = false +pyramid.debug_authorization = false +pyramid.debug_notfound = false +pyramid.debug_routematch = false +pyramid.default_locale_name = en + +zodbconn.uri = file://%(here)s/Data.testing.fs?connection_cache_size=20000 + +retry.attempts = 3 + +[pshell] +setup = tutorial.pshell.setup + +### +# wsgi server configuration +### + +[server:main] +use = egg:waitress#main +listen = localhost:6543 + +### +# logging configuration +# https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/logging.html +### + +[loggers] +keys = root, tutorial + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = INFO +handlers = console + +[logger_tutorial] +level = DEBUG +handlers = +qualname = tutorial + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(asctime)s %(levelname)-5.5s [%(name)s:%(lineno)s][%(threadName)s] %(message)s diff --git a/docs/tutorials/wiki/src/basiclayout/tests/conftest.py b/docs/tutorials/wiki/src/basiclayout/tests/conftest.py new file mode 100644 index 000000000..12e75d8e9 --- /dev/null +++ b/docs/tutorials/wiki/src/basiclayout/tests/conftest.py @@ -0,0 +1,69 @@ +import os +from pyramid.paster import get_appsettings +from pyramid.scripting import prepare +from pyramid.testing import DummyRequest +import pytest +import webtest + +from tutorial import main + + +def pytest_addoption(parser): + parser.addoption('--ini', action='store', metavar='INI_FILE') + +@pytest.fixture(scope='session') +def ini_file(request): + # potentially grab this path from a pytest option + return os.path.abspath(request.config.option.ini or 'testing.ini') + +@pytest.fixture(scope='session') +def app_settings(ini_file): + return get_appsettings(ini_file) + +@pytest.fixture(scope='session') +def app(app_settings): + return main({}, **app_settings) + +@pytest.fixture +def testapp(app): + testapp = webtest.TestApp(app, extra_environ={ + 'HTTP_HOST': 'example.com', + }) + + return testapp + +@pytest.fixture +def app_request(app): + """ + A real request. + + This request is almost identical to a real request but it has some + drawbacks in tests as it's harder to mock data and is heavier. + + """ + env = prepare(registry=app.registry) + request = env['request'] + request.host = 'example.com' + + yield request + env['closer']() + +@pytest.fixture +def dummy_request(app): + """ + A lightweight dummy request. + + This request is ultra-lightweight and should be used only when the + request itself is not a large focus in the call-stack. + + It is way easier to mock and control side-effects using this object. + + - It does not have request extensions applied. + - Threadlocals are not properly pushed. + + """ + request = DummyRequest() + request.registry = app.registry + request.host = 'example.com' + + return request diff --git a/docs/tutorials/wiki/src/basiclayout/tests/test_functional.py b/docs/tutorials/wiki/src/basiclayout/tests/test_functional.py new file mode 100644 index 000000000..bac5d63f4 --- /dev/null +++ b/docs/tutorials/wiki/src/basiclayout/tests/test_functional.py @@ -0,0 +1,7 @@ +def test_root(testapp): + res = testapp.get('/', status=200) + assert b'Pyramid' in res.body + +def test_notfound(testapp): + res = testapp.get('/badurl', status=404) + assert res.status_code == 404 diff --git a/docs/tutorials/wiki/src/basiclayout/tests/test_it.py b/docs/tutorials/wiki/src/basiclayout/tests/test_it.py deleted file mode 100644 index 6c72bcc62..000000000 --- a/docs/tutorials/wiki/src/basiclayout/tests/test_it.py +++ /dev/null @@ -1,24 +0,0 @@ -import unittest - -from pyramid import testing - - -class ViewTests(unittest.TestCase): - def setUp(self): - self.config = testing.setUp() - - def tearDown(self): - testing.tearDown() - - def test_my_view(self): - from tutorial.views.default import my_view - request = testing.DummyRequest() - info = my_view(request) - self.assertEqual(info['project'], 'myproj') - - def test_notfound_view(self): - from tutorial.views.notfound import notfound_view - request = testing.DummyRequest() - info = notfound_view(request) - self.assertEqual(info, {}) - diff --git a/docs/tutorials/wiki/src/basiclayout/tests/test_views.py b/docs/tutorials/wiki/src/basiclayout/tests/test_views.py new file mode 100644 index 000000000..2b4201955 --- /dev/null +++ b/docs/tutorials/wiki/src/basiclayout/tests/test_views.py @@ -0,0 +1,13 @@ +from tutorial.views.default import my_view +from tutorial.views.notfound import notfound_view + + +def test_my_view(app_request): + info = my_view(app_request) + assert app_request.response.status_int == 200 + assert info['project'] == 'myproj' + +def test_notfound_view(app_request): + info = notfound_view(app_request) + assert app_request.response.status_int == 404 + assert info == {} diff --git a/docs/tutorials/wiki/src/basiclayout/tutorial/__init__.py b/docs/tutorials/wiki/src/basiclayout/tutorial/__init__.py index 830a607f3..e40451339 100644 --- a/docs/tutorials/wiki/src/basiclayout/tutorial/__init__.py +++ b/docs/tutorials/wiki/src/basiclayout/tutorial/__init__.py @@ -1,5 +1,6 @@ from pyramid.config import Configurator from pyramid_zodbconn import get_connection + from .models import appmaker @@ -12,11 +13,11 @@ def main(global_config, **settings): """ This function returns a Pyramid WSGI application. """ with Configurator(settings=settings) as config: + config.include('pyramid_chameleon') config.include('pyramid_tm') config.include('pyramid_retry') config.include('pyramid_zodbconn') - config.set_root_factory(root_factory) - config.include('pyramid_chameleon') config.include('.routes') + config.set_root_factory(root_factory) config.scan() return config.make_wsgi_app() diff --git a/docs/tutorials/wiki/src/installation/.gitignore b/docs/tutorials/wiki/src/installation/.gitignore index 1853d983c..c612e59f2 100644 --- a/docs/tutorials/wiki/src/installation/.gitignore +++ b/docs/tutorials/wiki/src/installation/.gitignore @@ -19,3 +19,4 @@ Data.fs* .DS_Store coverage test +*.sqlite diff --git a/docs/tutorials/wiki/src/installation/testing.ini b/docs/tutorials/wiki/src/installation/testing.ini new file mode 100644 index 000000000..9298354ac --- /dev/null +++ b/docs/tutorials/wiki/src/installation/testing.ini @@ -0,0 +1,60 @@ +### +# app configuration +# https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/environment.html +### + +[app:main] +use = egg:tutorial + +pyramid.reload_templates = false +pyramid.debug_authorization = false +pyramid.debug_notfound = false +pyramid.debug_routematch = false +pyramid.default_locale_name = en + +zodbconn.uri = file://%(here)s/Data.testing.fs?connection_cache_size=20000 + +retry.attempts = 3 + +[pshell] +setup = tutorial.pshell.setup + +### +# wsgi server configuration +### + +[server:main] +use = egg:waitress#main +listen = localhost:6543 + +### +# logging configuration +# https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/logging.html +### + +[loggers] +keys = root, tutorial + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = INFO +handlers = console + +[logger_tutorial] +level = DEBUG +handlers = +qualname = tutorial + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(asctime)s %(levelname)-5.5s [%(name)s:%(lineno)s][%(threadName)s] %(message)s diff --git a/docs/tutorials/wiki/src/installation/tests/conftest.py b/docs/tutorials/wiki/src/installation/tests/conftest.py new file mode 100644 index 000000000..12e75d8e9 --- /dev/null +++ b/docs/tutorials/wiki/src/installation/tests/conftest.py @@ -0,0 +1,69 @@ +import os +from pyramid.paster import get_appsettings +from pyramid.scripting import prepare +from pyramid.testing import DummyRequest +import pytest +import webtest + +from tutorial import main + + +def pytest_addoption(parser): + parser.addoption('--ini', action='store', metavar='INI_FILE') + +@pytest.fixture(scope='session') +def ini_file(request): + # potentially grab this path from a pytest option + return os.path.abspath(request.config.option.ini or 'testing.ini') + +@pytest.fixture(scope='session') +def app_settings(ini_file): + return get_appsettings(ini_file) + +@pytest.fixture(scope='session') +def app(app_settings): + return main({}, **app_settings) + +@pytest.fixture +def testapp(app): + testapp = webtest.TestApp(app, extra_environ={ + 'HTTP_HOST': 'example.com', + }) + + return testapp + +@pytest.fixture +def app_request(app): + """ + A real request. + + This request is almost identical to a real request but it has some + drawbacks in tests as it's harder to mock data and is heavier. + + """ + env = prepare(registry=app.registry) + request = env['request'] + request.host = 'example.com' + + yield request + env['closer']() + +@pytest.fixture +def dummy_request(app): + """ + A lightweight dummy request. + + This request is ultra-lightweight and should be used only when the + request itself is not a large focus in the call-stack. + + It is way easier to mock and control side-effects using this object. + + - It does not have request extensions applied. + - Threadlocals are not properly pushed. + + """ + request = DummyRequest() + request.registry = app.registry + request.host = 'example.com' + + return request diff --git a/docs/tutorials/wiki/src/installation/tests/test_functional.py b/docs/tutorials/wiki/src/installation/tests/test_functional.py new file mode 100644 index 000000000..bac5d63f4 --- /dev/null +++ b/docs/tutorials/wiki/src/installation/tests/test_functional.py @@ -0,0 +1,7 @@ +def test_root(testapp): + res = testapp.get('/', status=200) + assert b'Pyramid' in res.body + +def test_notfound(testapp): + res = testapp.get('/badurl', status=404) + assert res.status_code == 404 diff --git a/docs/tutorials/wiki/src/installation/tests/test_it.py b/docs/tutorials/wiki/src/installation/tests/test_it.py deleted file mode 100644 index 6c72bcc62..000000000 --- a/docs/tutorials/wiki/src/installation/tests/test_it.py +++ /dev/null @@ -1,24 +0,0 @@ -import unittest - -from pyramid import testing - - -class ViewTests(unittest.TestCase): - def setUp(self): - self.config = testing.setUp() - - def tearDown(self): - testing.tearDown() - - def test_my_view(self): - from tutorial.views.default import my_view - request = testing.DummyRequest() - info = my_view(request) - self.assertEqual(info['project'], 'myproj') - - def test_notfound_view(self): - from tutorial.views.notfound import notfound_view - request = testing.DummyRequest() - info = notfound_view(request) - self.assertEqual(info, {}) - diff --git a/docs/tutorials/wiki/src/installation/tests/test_views.py b/docs/tutorials/wiki/src/installation/tests/test_views.py new file mode 100644 index 000000000..2b4201955 --- /dev/null +++ b/docs/tutorials/wiki/src/installation/tests/test_views.py @@ -0,0 +1,13 @@ +from tutorial.views.default import my_view +from tutorial.views.notfound import notfound_view + + +def test_my_view(app_request): + info = my_view(app_request) + assert app_request.response.status_int == 200 + assert info['project'] == 'myproj' + +def test_notfound_view(app_request): + info = notfound_view(app_request) + assert app_request.response.status_int == 404 + assert info == {} diff --git a/docs/tutorials/wiki/src/installation/tutorial/__init__.py b/docs/tutorials/wiki/src/installation/tutorial/__init__.py index 830a607f3..e40451339 100644 --- a/docs/tutorials/wiki/src/installation/tutorial/__init__.py +++ b/docs/tutorials/wiki/src/installation/tutorial/__init__.py @@ -1,5 +1,6 @@ from pyramid.config import Configurator from pyramid_zodbconn import get_connection + from .models import appmaker @@ -12,11 +13,11 @@ def main(global_config, **settings): """ This function returns a Pyramid WSGI application. """ with Configurator(settings=settings) as config: + config.include('pyramid_chameleon') config.include('pyramid_tm') config.include('pyramid_retry') config.include('pyramid_zodbconn') - config.set_root_factory(root_factory) - config.include('pyramid_chameleon') config.include('.routes') + config.set_root_factory(root_factory) config.scan() return config.make_wsgi_app() diff --git a/docs/tutorials/wiki/src/models/.gitignore b/docs/tutorials/wiki/src/models/.gitignore index 1853d983c..c612e59f2 100644 --- a/docs/tutorials/wiki/src/models/.gitignore +++ b/docs/tutorials/wiki/src/models/.gitignore @@ -19,3 +19,4 @@ Data.fs* .DS_Store coverage test +*.sqlite diff --git a/docs/tutorials/wiki/src/models/testing.ini b/docs/tutorials/wiki/src/models/testing.ini new file mode 100644 index 000000000..9298354ac --- /dev/null +++ b/docs/tutorials/wiki/src/models/testing.ini @@ -0,0 +1,60 @@ +### +# app configuration +# https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/environment.html +### + +[app:main] +use = egg:tutorial + +pyramid.reload_templates = false +pyramid.debug_authorization = false +pyramid.debug_notfound = false +pyramid.debug_routematch = false +pyramid.default_locale_name = en + +zodbconn.uri = file://%(here)s/Data.testing.fs?connection_cache_size=20000 + +retry.attempts = 3 + +[pshell] +setup = tutorial.pshell.setup + +### +# wsgi server configuration +### + +[server:main] +use = egg:waitress#main +listen = localhost:6543 + +### +# logging configuration +# https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/logging.html +### + +[loggers] +keys = root, tutorial + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = INFO +handlers = console + +[logger_tutorial] +level = DEBUG +handlers = +qualname = tutorial + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(asctime)s %(levelname)-5.5s [%(name)s:%(lineno)s][%(threadName)s] %(message)s diff --git a/docs/tutorials/wiki/src/models/tests/conftest.py b/docs/tutorials/wiki/src/models/tests/conftest.py new file mode 100644 index 000000000..12e75d8e9 --- /dev/null +++ b/docs/tutorials/wiki/src/models/tests/conftest.py @@ -0,0 +1,69 @@ +import os +from pyramid.paster import get_appsettings +from pyramid.scripting import prepare +from pyramid.testing import DummyRequest +import pytest +import webtest + +from tutorial import main + + +def pytest_addoption(parser): + parser.addoption('--ini', action='store', metavar='INI_FILE') + +@pytest.fixture(scope='session') +def ini_file(request): + # potentially grab this path from a pytest option + return os.path.abspath(request.config.option.ini or 'testing.ini') + +@pytest.fixture(scope='session') +def app_settings(ini_file): + return get_appsettings(ini_file) + +@pytest.fixture(scope='session') +def app(app_settings): + return main({}, **app_settings) + +@pytest.fixture +def testapp(app): + testapp = webtest.TestApp(app, extra_environ={ + 'HTTP_HOST': 'example.com', + }) + + return testapp + +@pytest.fixture +def app_request(app): + """ + A real request. + + This request is almost identical to a real request but it has some + drawbacks in tests as it's harder to mock data and is heavier. + + """ + env = prepare(registry=app.registry) + request = env['request'] + request.host = 'example.com' + + yield request + env['closer']() + +@pytest.fixture +def dummy_request(app): + """ + A lightweight dummy request. + + This request is ultra-lightweight and should be used only when the + request itself is not a large focus in the call-stack. + + It is way easier to mock and control side-effects using this object. + + - It does not have request extensions applied. + - Threadlocals are not properly pushed. + + """ + request = DummyRequest() + request.registry = app.registry + request.host = 'example.com' + + return request diff --git a/docs/tutorials/wiki/src/models/tests/test_functional.py b/docs/tutorials/wiki/src/models/tests/test_functional.py new file mode 100644 index 000000000..bac5d63f4 --- /dev/null +++ b/docs/tutorials/wiki/src/models/tests/test_functional.py @@ -0,0 +1,7 @@ +def test_root(testapp): + res = testapp.get('/', status=200) + assert b'Pyramid' in res.body + +def test_notfound(testapp): + res = testapp.get('/badurl', status=404) + assert res.status_code == 404 diff --git a/docs/tutorials/wiki/src/models/tests/test_it.py b/docs/tutorials/wiki/src/models/tests/test_it.py deleted file mode 100644 index 6c72bcc62..000000000 --- a/docs/tutorials/wiki/src/models/tests/test_it.py +++ /dev/null @@ -1,24 +0,0 @@ -import unittest - -from pyramid import testing - - -class ViewTests(unittest.TestCase): - def setUp(self): - self.config = testing.setUp() - - def tearDown(self): - testing.tearDown() - - def test_my_view(self): - from tutorial.views.default import my_view - request = testing.DummyRequest() - info = my_view(request) - self.assertEqual(info['project'], 'myproj') - - def test_notfound_view(self): - from tutorial.views.notfound import notfound_view - request = testing.DummyRequest() - info = notfound_view(request) - self.assertEqual(info, {}) - diff --git a/docs/tutorials/wiki/src/models/tests/test_views.py b/docs/tutorials/wiki/src/models/tests/test_views.py new file mode 100644 index 000000000..2b4201955 --- /dev/null +++ b/docs/tutorials/wiki/src/models/tests/test_views.py @@ -0,0 +1,13 @@ +from tutorial.views.default import my_view +from tutorial.views.notfound import notfound_view + + +def test_my_view(app_request): + info = my_view(app_request) + assert app_request.response.status_int == 200 + assert info['project'] == 'myproj' + +def test_notfound_view(app_request): + info = notfound_view(app_request) + assert app_request.response.status_int == 404 + assert info == {} diff --git a/docs/tutorials/wiki/src/models/tutorial/__init__.py b/docs/tutorials/wiki/src/models/tutorial/__init__.py index 830a607f3..e40451339 100644 --- a/docs/tutorials/wiki/src/models/tutorial/__init__.py +++ b/docs/tutorials/wiki/src/models/tutorial/__init__.py @@ -1,5 +1,6 @@ from pyramid.config import Configurator from pyramid_zodbconn import get_connection + from .models import appmaker @@ -12,11 +13,11 @@ def main(global_config, **settings): """ This function returns a Pyramid WSGI application. """ with Configurator(settings=settings) as config: + config.include('pyramid_chameleon') config.include('pyramid_tm') config.include('pyramid_retry') config.include('pyramid_zodbconn') - config.set_root_factory(root_factory) - config.include('pyramid_chameleon') config.include('.routes') + config.set_root_factory(root_factory) config.scan() return config.make_wsgi_app() diff --git a/docs/tutorials/wiki/src/views/.gitignore b/docs/tutorials/wiki/src/views/.gitignore index 1853d983c..c612e59f2 100644 --- a/docs/tutorials/wiki/src/views/.gitignore +++ b/docs/tutorials/wiki/src/views/.gitignore @@ -19,3 +19,4 @@ Data.fs* .DS_Store coverage test +*.sqlite diff --git a/docs/tutorials/wiki/src/views/setup.py b/docs/tutorials/wiki/src/views/setup.py index 439bb7759..86c778bf2 100644 --- a/docs/tutorials/wiki/src/views/setup.py +++ b/docs/tutorials/wiki/src/views/setup.py @@ -9,6 +9,7 @@ with open(os.path.join(here, 'CHANGES.txt')) as f: CHANGES = f.read() requires = [ + 'docutils', 'plaster_pastedeploy', 'pyramid', 'pyramid_chameleon', @@ -19,7 +20,6 @@ requires = [ 'pyramid_zodbconn', 'transaction', 'ZODB3', - 'docutils', ] tests_require = [ diff --git a/docs/tutorials/wiki/src/views/testing.ini b/docs/tutorials/wiki/src/views/testing.ini new file mode 100644 index 000000000..9298354ac --- /dev/null +++ b/docs/tutorials/wiki/src/views/testing.ini @@ -0,0 +1,60 @@ +### +# app configuration +# https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/environment.html +### + +[app:main] +use = egg:tutorial + +pyramid.reload_templates = false +pyramid.debug_authorization = false +pyramid.debug_notfound = false +pyramid.debug_routematch = false +pyramid.default_locale_name = en + +zodbconn.uri = file://%(here)s/Data.testing.fs?connection_cache_size=20000 + +retry.attempts = 3 + +[pshell] +setup = tutorial.pshell.setup + +### +# wsgi server configuration +### + +[server:main] +use = egg:waitress#main +listen = localhost:6543 + +### +# logging configuration +# https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/logging.html +### + +[loggers] +keys = root, tutorial + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = INFO +handlers = console + +[logger_tutorial] +level = DEBUG +handlers = +qualname = tutorial + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(asctime)s %(levelname)-5.5s [%(name)s:%(lineno)s][%(threadName)s] %(message)s diff --git a/docs/tutorials/wiki/src/views/tests/conftest.py b/docs/tutorials/wiki/src/views/tests/conftest.py new file mode 100644 index 000000000..12e75d8e9 --- /dev/null +++ b/docs/tutorials/wiki/src/views/tests/conftest.py @@ -0,0 +1,69 @@ +import os +from pyramid.paster import get_appsettings +from pyramid.scripting import prepare +from pyramid.testing import DummyRequest +import pytest +import webtest + +from tutorial import main + + +def pytest_addoption(parser): + parser.addoption('--ini', action='store', metavar='INI_FILE') + +@pytest.fixture(scope='session') +def ini_file(request): + # potentially grab this path from a pytest option + return os.path.abspath(request.config.option.ini or 'testing.ini') + +@pytest.fixture(scope='session') +def app_settings(ini_file): + return get_appsettings(ini_file) + +@pytest.fixture(scope='session') +def app(app_settings): + return main({}, **app_settings) + +@pytest.fixture +def testapp(app): + testapp = webtest.TestApp(app, extra_environ={ + 'HTTP_HOST': 'example.com', + }) + + return testapp + +@pytest.fixture +def app_request(app): + """ + A real request. + + This request is almost identical to a real request but it has some + drawbacks in tests as it's harder to mock data and is heavier. + + """ + env = prepare(registry=app.registry) + request = env['request'] + request.host = 'example.com' + + yield request + env['closer']() + +@pytest.fixture +def dummy_request(app): + """ + A lightweight dummy request. + + This request is ultra-lightweight and should be used only when the + request itself is not a large focus in the call-stack. + + It is way easier to mock and control side-effects using this object. + + - It does not have request extensions applied. + - Threadlocals are not properly pushed. + + """ + request = DummyRequest() + request.registry = app.registry + request.host = 'example.com' + + return request diff --git a/docs/tutorials/wiki/src/views/tests/test_functional.py b/docs/tutorials/wiki/src/views/tests/test_functional.py new file mode 100644 index 000000000..bac5d63f4 --- /dev/null +++ b/docs/tutorials/wiki/src/views/tests/test_functional.py @@ -0,0 +1,7 @@ +def test_root(testapp): + res = testapp.get('/', status=200) + assert b'Pyramid' in res.body + +def test_notfound(testapp): + res = testapp.get('/badurl', status=404) + assert res.status_code == 404 diff --git a/docs/tutorials/wiki/src/views/tests/test_it.py b/docs/tutorials/wiki/src/views/tests/test_it.py deleted file mode 100644 index 6c72bcc62..000000000 --- a/docs/tutorials/wiki/src/views/tests/test_it.py +++ /dev/null @@ -1,24 +0,0 @@ -import unittest - -from pyramid import testing - - -class ViewTests(unittest.TestCase): - def setUp(self): - self.config = testing.setUp() - - def tearDown(self): - testing.tearDown() - - def test_my_view(self): - from tutorial.views.default import my_view - request = testing.DummyRequest() - info = my_view(request) - self.assertEqual(info['project'], 'myproj') - - def test_notfound_view(self): - from tutorial.views.notfound import notfound_view - request = testing.DummyRequest() - info = notfound_view(request) - self.assertEqual(info, {}) - diff --git a/docs/tutorials/wiki/src/views/tests/test_views.py b/docs/tutorials/wiki/src/views/tests/test_views.py new file mode 100644 index 000000000..2b4201955 --- /dev/null +++ b/docs/tutorials/wiki/src/views/tests/test_views.py @@ -0,0 +1,13 @@ +from tutorial.views.default import my_view +from tutorial.views.notfound import notfound_view + + +def test_my_view(app_request): + info = my_view(app_request) + assert app_request.response.status_int == 200 + assert info['project'] == 'myproj' + +def test_notfound_view(app_request): + info = notfound_view(app_request) + assert app_request.response.status_int == 404 + assert info == {} diff --git a/docs/tutorials/wiki/src/views/tutorial/__init__.py b/docs/tutorials/wiki/src/views/tutorial/__init__.py index 830a607f3..e40451339 100644 --- a/docs/tutorials/wiki/src/views/tutorial/__init__.py +++ b/docs/tutorials/wiki/src/views/tutorial/__init__.py @@ -1,5 +1,6 @@ from pyramid.config import Configurator from pyramid_zodbconn import get_connection + from .models import appmaker @@ -12,11 +13,11 @@ def main(global_config, **settings): """ This function returns a Pyramid WSGI application. """ with Configurator(settings=settings) as config: + config.include('pyramid_chameleon') config.include('pyramid_tm') config.include('pyramid_retry') config.include('pyramid_zodbconn') - config.set_root_factory(root_factory) - config.include('pyramid_chameleon') config.include('.routes') + config.set_root_factory(root_factory) config.scan() return config.make_wsgi_app() diff --git a/docs/tutorials/wiki/src/views/tutorial/templates/layout.pt b/docs/tutorials/wiki/src/views/tutorial/templates/layout.pt index 06a3c8157..1e8b808d4 100644 --- a/docs/tutorials/wiki/src/views/tutorial/templates/layout.pt +++ b/docs/tutorials/wiki/src/views/tutorial/templates/layout.pt @@ -8,8 +8,7 @@ <meta name="author" content="Pylons Project"> <link rel="shortcut icon" href="${request.static_url('tutorial:static/pyramid-16x16.png')}"> - <title><span tal:replace="page.__name__ | title"></span> - Pyramid tutorial wiki (based on - TurboGears 20-Minute Wiki) + <span tal:replace="page.__name__ | title"></span> - Pyramid tutorial wiki (based on TurboGears 20-Minute Wiki) @@ -41,6 +40,15 @@ +