diff options
| author | Michael Merickel <michael@merickel.org> | 2020-01-16 10:09:45 -0600 |
|---|---|---|
| committer | Michael Merickel <michael@merickel.org> | 2020-01-16 10:09:45 -0600 |
| commit | a7f61dc1ae95ffddacccfb583fa7a8f6d294f4b9 (patch) | |
| tree | 8aca99052f7086bcb37609b516d7a11902377d71 | |
| parent | eb7046c8eeb8c9b598260ae8c8976187a8f84953 (diff) | |
| parent | 9c153e1250e00faa06003c10c3a26886489e6210 (diff) | |
| download | pyramid-a7f61dc1ae95ffddacccfb583fa7a8f6d294f4b9.tar.gz pyramid-a7f61dc1ae95ffddacccfb583fa7a8f6d294f4b9.tar.bz2 pyramid-a7f61dc1ae95ffddacccfb583fa7a8f6d294f4b9.zip | |
Merge branch 'master' into move-acl-security-to-authorization
211 files changed, 4973 insertions, 2253 deletions
diff --git a/CHANGES.rst b/CHANGES.rst index f1ccdf8e6..04bec0874 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -238,6 +238,10 @@ Backward Incompatibilities ``require_csrf`` view option to enable automatic CSRF checking. See https://github.com/Pylons/pyramid/pull/3521 +- Changed the default ``hashalg`` on + ``pyramid.authentication.AuthTktCookieHelper`` to ``sha512``. + See https://github.com/Pylons/pyramid/pull/3557 + Documentation Changes --------------------- diff --git a/docs/conf.py b/docs/conf.py index 365af5fdb..472485380 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -72,8 +72,10 @@ intersphinx_mapping = { 'sqla': ('https://docs.sqlalchemy.org/en/latest/', None), 'tm': ('https://docs.pylonsproject.org/projects/pyramid-tm/en/latest/', None), 'toolbar': ('https://docs.pylonsproject.org/projects/pyramid-debugtoolbar/en/latest/', None), + 'transaction': ('https://transaction.readthedocs.io/en/latest/', None), 'tutorials': ('https://docs.pylonsproject.org/projects/pyramid-tutorials/en/latest/', None), 'venusian': ('https://docs.pylonsproject.org/projects/venusian/en/latest/', None), + 'webtest': ('https://docs.pylonsproject.org/projects/webtest/en/latest/', None), 'zcml': ( 'https://docs.pylonsproject.org/projects/pyramid-zcml/en/latest/', None), } diff --git a/docs/narr/myproject/.gitignore b/docs/narr/myproject/.gitignore index 1853d983c..c612e59f2 100644 --- a/docs/narr/myproject/.gitignore +++ b/docs/narr/myproject/.gitignore @@ -19,3 +19,4 @@ Data.fs* .DS_Store coverage test +*.sqlite diff --git a/docs/narr/myproject/testing.ini b/docs/narr/myproject/testing.ini new file mode 100644 index 000000000..f2ef86805 --- /dev/null +++ b/docs/narr/myproject/testing.ini @@ -0,0 +1,53 @@ +### +# app configuration +# https://docs.pylonsproject.org/projects/pyramid/en/latest/narr/environment.html +### + +[app:main] +use = egg:myproject + +pyramid.reload_templates = false +pyramid.debug_authorization = false +pyramid.debug_notfound = false +pyramid.debug_routematch = false +pyramid.default_locale_name = en + +### +# 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, myproject + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = INFO +handlers = console + +[logger_myproject] +level = DEBUG +handlers = +qualname = myproject + +[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/narr/myproject/tests/conftest.py b/docs/narr/myproject/tests/conftest.py new file mode 100644 index 000000000..296205927 --- /dev/null +++ b/docs/narr/myproject/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 myproject 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/narr/myproject/tests/test_functional.py b/docs/narr/myproject/tests/test_functional.py new file mode 100644 index 000000000..bac5d63f4 --- /dev/null +++ b/docs/narr/myproject/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/narr/myproject/tests/test_it.py b/docs/narr/myproject/tests/test_it.py deleted file mode 100644 index b300da34d..000000000 --- a/docs/narr/myproject/tests/test_it.py +++ /dev/null @@ -1,39 +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 myproject.views.default import my_view - request = testing.DummyRequest() - info = my_view(request) - self.assertEqual(info['project'], 'myproject') - - def test_notfound_view(self): - from myproject.views.notfound import notfound_view - request = testing.DummyRequest() - info = notfound_view(request) - self.assertEqual(info, {}) - - -class FunctionalTests(unittest.TestCase): - def setUp(self): - from myproject import main - app = main({}) - from webtest import TestApp - self.testapp = TestApp(app) - - def test_root(self): - res = self.testapp.get('/', status=200) - self.assertTrue(b'Pyramid' in res.body) - - def test_notfound(self): - res = self.testapp.get('/badurl', status=404) - self.assertTrue(res.status_code == 404) diff --git a/docs/narr/myproject/tests/test_views.py b/docs/narr/myproject/tests/test_views.py new file mode 100644 index 000000000..1fd9db8ab --- /dev/null +++ b/docs/narr/myproject/tests/test_views.py @@ -0,0 +1,13 @@ +from myproject.views.default import my_view +from myproject.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'] == 'myproject' + +def test_notfound_view(app_request): + info = notfound_view(app_request) + assert app_request.response.status_int == 404 + assert info == {} diff --git a/docs/narr/project.rst b/docs/narr/project.rst index 043f77754..6493f0fe7 100644 --- a/docs/narr/project.rst +++ b/docs/narr/project.rst @@ -210,9 +210,9 @@ Elided output from a run of this command on Unix is shown below: Running setup.py develop for myproject Successfully installed Jinja2-2.10.3 Mako-1.1.0 MarkupSafe-1.1.1 \ PasteDeploy-2.0.1 Pygments-2.5.2 hupper-1.9.1 myproject plaster-1.0 \ - plaster-pastedeploy-0.7 pyramid-1.10.4 pyramid-debugtoolbar-4.5.1 \ + plaster-pastedeploy-0.7 pyramid-1.10.4 pyramid-debugtoolbar-4.5.2 \ pyramid-jinja2-2.8 pyramid-mako-1.1.0 repoze.lru-0.7 \ - translationstring-1.3 venusian-3.0.0 waitress-1.4.1 webob-1.8.5 \ + translationstring-1.3 venusian-3.0.0 waitress-1.4.2 webob-1.8.5 \ zope.deprecation-4.4.0 zope.interface-4.7.1 This will install a :term:`distribution` representing your project into the @@ -264,9 +264,9 @@ Here's sample output from a test run on Unix: $VENV/bin/pytest -q .... - 4 passed in 0.45 seconds + 4 passed in 0.31s -The tests themselves are found in the ``test_it.py`` module in the ``tests`` package in your ``cookiecutter``-generated project. +The tests themselves are found in the ``tests`` package in your ``cookiecutter``-generated project. Within this project generated by the ``pyramid-cookiecutter-starter`` cookiecutter, only a few sample tests exist. .. note:: @@ -555,10 +555,12 @@ The ``myproject`` project we've generated has the following directory structure: │  └── notfound.py ├── production.ini ├── pytest.ini - ├── setup.py + ├── testing.ini └── tests ├── __init__.py - └── test_it.py + ├── conftest.py + ├── test_functional.py + └── test_views.py .. index:: @@ -567,17 +569,27 @@ The ``myproject`` project we've generated has the following directory structure: ``test_it.py`` ~~~~~~~~~~~~~~ -The ``test_it.py`` module in the ``tests`` package includes tests for your application. +The ``conftest.py``, ``test_functional.py``, and ``test_views.py`` modules in the ``tests`` package includes tests for your application. -.. literalinclude:: myproject/tests/test_it.py +.. literalinclude:: myproject/tests/conftest.py :language: python :linenos: -This sample ``test_it.py`` file has two unit tests and two functional tests defined within it. +.. literalinclude:: myproject/tests/test_functional.py + :language: python + :linenos: + +.. literalinclude:: myproject/tests/test_views.py + :language: python + :linenos: + +The sample ``conftest.py`` file contains test configuration and fixtures. +The sample ``test_functional.py`` file has two functional tests defined within it. +The sample ``test_views.py`` file has two unit tests defined within it. These tests are executed when you run ``pytest -q``. You may add more tests here as you build your application. You are not required to write tests to use :app:`Pyramid`. -This file is simply provided for convenience and example. +These files are provided only for convenience and example. See :ref:`testing_chapter` for more information about writing :app:`Pyramid` unit tests. @@ -615,6 +627,8 @@ describe, run, and test your application. #. ``setup.py`` is the file you'll use to test and distribute your application. It is a standard :term:`Setuptools` ``setup.py`` file. +#. ``testing.ini`` is a :term:`PasteDeploy` configuration file that can be used to execute your application's tests. + #. ``tests`` package which contains unit and functional test code for the application. .. index:: @@ -717,6 +731,19 @@ inclusion of this toolbar slows down page rendering times by over an order of magnitude. The debug toolbar is also a potential security risk if you have it configured incorrectly. + +.. index:: + single: testing.ini + +``testing.ini`` +~~~~~~~~~~~~~~~ + +The ``testing.ini`` file is a :term:`PasteDeploy` configuration file with a purpose much like that of ``development.ini``. +It is similar to ``development.ini``, but is optimized to reduce test execution time. +It disables the debug toolbar and automatic reloading of templates, as these slow down test execution. +This file is appropriate to use instead of ``development.ini`` when you run your application's tests. + + .. index:: single: MANIFEST.in diff --git a/docs/narr/security.rst b/docs/narr/security.rst index fd291a9db..10e9df78d 100644 --- a/docs/narr/security.rst +++ b/docs/narr/security.rst @@ -69,7 +69,7 @@ A simple security policy might look like the following: from pyramid.security import Allowed, Denied class SessionSecurityPolicy: - def identify(self, request): + def authenticated_identity(self, request): """ Return app-specific user object. """ userid = request.session.get('userid') if userid is None: @@ -78,14 +78,14 @@ A simple security policy might look like the following: def authenticated_userid(self, request): """ Return a string ID for the user. """ - identity = self.identify(request) + identity = self.authenticated_identity(request) if identity is None: return None return string(identity.id) def permits(self, request, context, permission): """ Allow access to everything if signed in. """ - identity = self.identify(request) + identity = self.authenticated_identity(request) if identity is not None: return Allowed('User is signed in.') else: @@ -144,7 +144,7 @@ For example, our above security policy can leverage these helpers like so: def __init__(self): self.helper = SessionAuthenticationHelper() - def identify(self, request): + def authenticated_identity(self, request): """ Return app-specific user object. """ userid = self.helper.authenticated_userid(request) if userid is None: @@ -153,14 +153,14 @@ For example, our above security policy can leverage these helpers like so: def authenticated_userid(self, request): """ Return a string ID for the user. """ - identity = self.identify(request) + identity = self.authenticated_identity(request) if identity is None: return None return str(identity.id) def permits(self, request, context, permission): """ Allow access to everything if signed in. """ - identity = self.identify(request) + identity = self.authenticated_identity(request) if identity is not None: return Allowed('User is signed in.') else: @@ -249,7 +249,7 @@ might look like so: class SecurityPolicy: def permits(self, request, context, permission): - identity = self.identify(request) + identity = self.authenticated_identity(request) if identity is None: return Denied('User is not signed in.') @@ -697,7 +697,7 @@ A "secret" is required by various components of Pyramid. For example, the helper below might be used for a security policy and uses a secret value ``seekrit``:: - helper = AuthTktCookieHelper('seekrit', hashalg='sha512') + helper = AuthTktCookieHelper('seekrit') A :term:`session factory` also requires a secret:: @@ -719,6 +719,8 @@ has the possibility of providing a chosen plaintext. single: preventing cross-site request forgery attacks single: cross-site request forgery attacks, prevention +.. _csrf_protection: + Preventing Cross-Site Request Forgery Attacks --------------------------------------------- diff --git a/docs/narr/testing.rst b/docs/narr/testing.rst index 0fa1e98fd..bb23d5137 100644 --- a/docs/narr/testing.rst +++ b/docs/narr/testing.rst @@ -395,23 +395,30 @@ As always, whenever you change your dependencies, make sure to run the correct $VENV/bin/pip install -e ".[testing]" -In your ``MyPackage`` project, your :term:`package` is named ``myproject`` +In your ``myproject`` project, your :term:`package` is named ``myproject`` which contains a ``views`` package containing a ``default.py`` module, which in turn contains a :term:`view` function ``my_view`` that returns an HTML body when the root URL is invoked: - .. literalinclude:: myproject/myproject/views/default.py - :linenos: - :language: python + .. literalinclude:: myproject/myproject/views/default.py + :linenos: + :language: python + +Test configuration and fixtures are defined in ``conftest.py``. +In the following example, we define a test fixture. + + .. literalinclude:: myproject/tests/conftest.py + :pyobject: testapp + :linenos: + :language: python -The following example functional tests demonstrate invoking the above :term:`view`: +This fixture is used in the following example functional tests, to demonstrate invoking the above :term:`view`: - .. literalinclude:: myproject/tests/test_it.py + .. literalinclude:: myproject/tests/test_functional.py :linenos: - :pyobject: FunctionalTests :language: python When these tests are run, each test method creates a "real" :term:`WSGI` application using the ``main`` function in your ``myproject.__init__`` module, using :term:`WebTest` to wrap that WSGI application. -It assigns the result to ``self.testapp``. +It assigns the result to ``res``. In the test named ``test_root``, the ``TestApp``'s ``GET`` method is used to invoke the root URL. An assertion is made that the returned HTML contains the text ``Pyramid``. diff --git a/docs/quick_tutorial/authentication.rst b/docs/quick_tutorial/authentication.rst index cd038ea36..3f6df17de 100644 --- a/docs/quick_tutorial/authentication.rst +++ b/docs/quick_tutorial/authentication.rst @@ -55,16 +55,15 @@ Steps :language: ini :linenos: -#. Get authentication (and for now, authorization policies) and login route - into the :term:`configurator` in ``authentication/tutorial/__init__.py``: +#. Create an ``authentication/tutorial/security.py`` module that can find our + user information by providing a :term:`security policy`: - .. literalinclude:: authentication/tutorial/__init__.py + .. literalinclude:: authentication/tutorial/security.py :linenos: -#. Create an ``authentication/tutorial/security.py`` module that can find our - user information by providing an *authentication policy callback*: +#. Register the ``SecurityPolicy`` with the :term:`configurator` in ``authentication/tutorial/__init__.py``: - .. literalinclude:: authentication/tutorial/security.py + .. literalinclude:: authentication/tutorial/__init__.py :linenos: #. Update the views in ``authentication/tutorial/views.py``: @@ -107,18 +106,16 @@ Analysis Unlike many web frameworks, Pyramid includes a built-in but optional security model for authentication and authorization. This security system is intended to be flexible and support many needs. In this security model, authentication (who -are you) and authorization (what are you allowed to do) are not just pluggable, -but decoupled. To learn one step at a time, we provide a system that identifies +are you) and authorization (what are you allowed to do) are pluggable. +To learn one step at a time, we provide a system that identifies users and lets them log out. -In this example we chose to use the bundled :ref:`AuthTktAuthenticationPolicy -<authentication_module>` policy. We enabled it in our configuration and -provided a ticket-signing secret in our INI file. +In this example we chose to use the bundled :class:`pyramid.authentication.AuthTktCookieHelper` helper to store the user's logged-in state in a cookie. +We enabled it in our configuration and provided a ticket-signing secret in our INI file. Our view class grew a login view. When you reached it via a ``GET`` request, it returned a login form. When reached via ``POST``, it processed the submitted -username and password against the "groupfinder" callable that we registered in -the configuration. +username and password against the ``USERS`` data store. The function ``hash_password`` uses a one-way hashing algorithm with a salt on the user's password via ``bcrypt``, instead of storing the password in plain @@ -134,6 +131,9 @@ submitted password and the user's password stored in the database. If the hashed values are equivalent, then the user is authenticated, else authentication fails. +Assuming the password was validated, we invoke :func:`pyramid.security.remember` to generate a cookie that is set in the response. +Subsequent requests return that cookie and identify the user. + In our template, we fetched the ``logged_in`` value from the view class. We use this to calculate the logged-in user, if any. In the template we can then choose to show a login link to anonymous visitors or a logout link to logged-in @@ -143,13 +143,9 @@ users. Extra credit ============ -#. What is the difference between a user and a principal? - -#. Can I use a database behind my ``groupfinder`` to look up principals? +#. Can I use a database instead of ``USERS`` to authenticate users? #. Once I am logged in, does any user-centric information get jammed onto each request? Use ``import pdb; pdb.set_trace()`` to answer this. -.. seealso:: See also :ref:`security_chapter`, - :ref:`AuthTktAuthenticationPolicy <authentication_module>`, `bcrypt - <https://pypi.org/project/bcrypt/>`_ +.. seealso:: See also :ref:`security_chapter`, :class:`pyramid.authentication.AuthTktCookieHelper`, `bcrypt <https://pypi.org/project/bcrypt/>`_ diff --git a/docs/quick_tutorial/authentication/tutorial/__init__.py b/docs/quick_tutorial/authentication/tutorial/__init__.py index efc09e760..ec8a66a23 100644 --- a/docs/quick_tutorial/authentication/tutorial/__init__.py +++ b/docs/quick_tutorial/authentication/tutorial/__init__.py @@ -1,25 +1,21 @@ -from pyramid.authentication import AuthTktAuthenticationPolicy -from pyramid.authorization import ACLAuthorizationPolicy from pyramid.config import Configurator -from .security import groupfinder +from .security import SecurityPolicy def main(global_config, **settings): config = Configurator(settings=settings) config.include('pyramid_chameleon') - # Security policies - authn_policy = AuthTktAuthenticationPolicy( - settings['tutorial.secret'], callback=groupfinder, - hashalg='sha512') - authz_policy = ACLAuthorizationPolicy() - config.set_authentication_policy(authn_policy) - config.set_authorization_policy(authz_policy) + config.set_security_policy( + SecurityPolicy( + secret=settings['tutorial.secret'], + ), + ) config.add_route('home', '/') config.add_route('hello', '/howdy') config.add_route('login', '/login') config.add_route('logout', '/logout') config.scan('.views') - return config.make_wsgi_app()
\ No newline at end of file + return config.make_wsgi_app() diff --git a/docs/quick_tutorial/authentication/tutorial/security.py b/docs/quick_tutorial/authentication/tutorial/security.py index e585e2642..8324000ed 100644 --- a/docs/quick_tutorial/authentication/tutorial/security.py +++ b/docs/quick_tutorial/authentication/tutorial/security.py @@ -1,4 +1,5 @@ import bcrypt +from pyramid.authentication import AuthTktCookieHelper def hash_password(pw): @@ -12,9 +13,24 @@ def check_password(pw, hashed_pw): USERS = {'editor': hash_password('editor'), 'viewer': hash_password('viewer')} -GROUPS = {'editor': ['group:editors']} -def groupfinder(userid, request): - if userid in USERS: - return GROUPS.get(userid, [])
\ No newline at end of file +class SecurityPolicy: + def __init__(self, secret): + self.authtkt = AuthTktCookieHelper(secret=secret) + + def authenticated_identity(self, request): + identity = self.authtkt.identify(request) + if identity is not None and identity['userid'] in USERS: + return identity + + def authenticated_userid(self, request): + identity = self.authenticated_identity(request) + if identity is not None: + return identity['userid'] + + def remember(self, request, userid, **kw): + return self.authtkt.remember(request, userid, **kw) + + def forget(self, request, **kw): + return self.authtkt.forget(request, **kw) diff --git a/docs/quick_tutorial/authorization.rst b/docs/quick_tutorial/authorization.rst index e80f88c51..b1ef86a17 100644 --- a/docs/quick_tutorial/authorization.rst +++ b/docs/quick_tutorial/authorization.rst @@ -55,6 +55,11 @@ Steps .. literalinclude:: authorization/tutorial/resources.py :linenos: +#. Define a ``GROUPS`` data store and the ``permits`` method of our ``SecurityPolicy``: + + .. literalinclude:: authorization/tutorial/security.py + :linenos: + #. Change ``authorization/tutorial/views.py`` to require the ``edit`` permission on the ``hello`` view and implement the forbidden view: @@ -87,8 +92,10 @@ This simple tutorial step can be boiled down to the following: - This ACL says that the ``edit`` permission is available on ``Root`` to the ``group:editors`` *principal*. -- The registered ``groupfinder`` answers whether a particular user (``editor``) - has a particular group (``group:editors``). +- The ``SecurityPolicy.effective_principals`` method answers whether a particular user (``editor``) is a member of a particular group (``group:editors``). + +- The ``SecurityPolicy.permits`` method is invoked when Pyramid wants to know whether the user is allowed to do something. + To do this, it uses the :class:`pyramid.authorization.ACLHelper` to inspect the ACL on the ``context`` and determine if the request is allowed or denied the specific permission. In summary, ``hello`` wants ``edit`` permission, ``Root`` says ``group:editors`` has ``edit`` permission. @@ -105,6 +112,10 @@ Pyramid that the ``login`` view should be used by decorating the view with Extra credit ============ +#. What is the difference between a user and a principal? + +#. Can I use a database instead of the ``GROUPS`` data store to look up principals? + #. Do I have to put a ``renderer`` in my ``@forbidden_view_config`` decorator? #. Perhaps you would like the experience of not having enough permissions diff --git a/docs/quick_tutorial/authorization/tutorial/__init__.py b/docs/quick_tutorial/authorization/tutorial/__init__.py index 8f7ab8277..255bb35ac 100644 --- a/docs/quick_tutorial/authorization/tutorial/__init__.py +++ b/docs/quick_tutorial/authorization/tutorial/__init__.py @@ -1,8 +1,6 @@ -from pyramid.authentication import AuthTktAuthenticationPolicy -from pyramid.authorization import ACLAuthorizationPolicy from pyramid.config import Configurator -from .security import groupfinder +from .security import SecurityPolicy def main(global_config, **settings): @@ -10,17 +8,15 @@ def main(global_config, **settings): root_factory='.resources.Root') config.include('pyramid_chameleon') - # Security policies - authn_policy = AuthTktAuthenticationPolicy( - settings['tutorial.secret'], callback=groupfinder, - hashalg='sha512') - authz_policy = ACLAuthorizationPolicy() - config.set_authentication_policy(authn_policy) - config.set_authorization_policy(authz_policy) + config.set_security_policy( + SecurityPolicy( + secret=settings['tutorial.secret'], + ), + ) config.add_route('home', '/') config.add_route('hello', '/howdy') config.add_route('login', '/login') config.add_route('logout', '/logout') config.scan('.views') - return config.make_wsgi_app()
\ No newline at end of file + return config.make_wsgi_app() diff --git a/docs/quick_tutorial/authorization/tutorial/security.py b/docs/quick_tutorial/authorization/tutorial/security.py index e585e2642..5b3e04a5f 100644 --- a/docs/quick_tutorial/authorization/tutorial/security.py +++ b/docs/quick_tutorial/authorization/tutorial/security.py @@ -1,4 +1,7 @@ import bcrypt +from pyramid.authentication import AuthTktCookieHelper +from pyramid.authorization import ACLHelper +from pyramid.security import Authenticated, Everyone def hash_password(pw): @@ -15,6 +18,35 @@ USERS = {'editor': hash_password('editor'), GROUPS = {'editor': ['group:editors']} -def groupfinder(userid, request): - if userid in USERS: - return GROUPS.get(userid, [])
\ No newline at end of file +class SecurityPolicy: + def __init__(self, secret): + self.authtkt = AuthTktCookieHelper(secret=secret) + self.acl = ACLHelper() + + def authenticated_identity(self, request): + identity = self.authtkt.identify(request) + if identity is not None and identity['userid'] in USERS: + return identity + + def authenticated_userid(self, request): + identity = self.authenticated_identity(request) + if identity is not None: + return identity['userid'] + + 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] + userid = self.authenticated_userid(request) + if userid is not None: + principals += [Authenticated, 'u:' + userid] + principals += GROUPS.get(userid, []) + return principals diff --git a/docs/tutorials/wiki/authorization.rst b/docs/tutorials/wiki/authorization.rst index 2ff9deb31..995dfa729 100644 --- a/docs/tutorials/wiki/authorization.rst +++ b/docs/tutorials/wiki/authorization.rst @@ -18,8 +18,8 @@ We will implement the access control with the following steps: - Add password hashing dependencies. - Add users and groups (``security.py``, a new module). +- Add a :term:`security policy` (``security.py``). - Add an :term:`ACL` (``models.py``). -- Add an :term:`authentication policy` and an :term:`authorization policy` (``__init__.py``). - Add :term:`permission` declarations to the ``edit_page`` and ``add_page`` views (``views.py``). Then we will add the login and logout features: @@ -43,8 +43,9 @@ We need to add the `bcrypt <https://pypi.org/project/bcrypt/>`_ package to our t Open ``setup.py`` and edit it to look like the following: .. literalinclude:: src/authorization/setup.py - :linenos: - :emphasize-lines: 23 + :lines: 11-30 + :lineno-match: + :emphasize-lines: 2 :language: python Only the highlighted line needs to be added. @@ -58,8 +59,8 @@ Do not forget to run ``pip install -e .`` just like in :ref:`wiki-running-pip-in Just make sure that it is an algorithm approved for storing passwords versus a generic one-way hash. -Add users and groups -~~~~~~~~~~~~~~~~~~~~ +Add the security policy +~~~~~~~~~~~~~~~~~~~~~~~ Create a new ``tutorial/security.py`` module with the following content: @@ -67,21 +68,52 @@ Create a new ``tutorial/security.py`` module with the following content: :linenos: :language: python -The ``groupfinder`` function accepts a ``userid`` and a ``request`` -It returns one of these values: +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/authorization/tutorial/__init__.py + :linenos: + :emphasize-lines: 21 + :language: python + +The security policy controls several aspects of authentication and authorization: + +- Identifying the current user's :term:`identity` for a ``request``. + +- Authorizating access to resources. + +- Creating payloads for remembering and forgetting users. + + +Identifying logged-in users +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The ``MySecurityPolicy.authenticated_identity`` method inspects the ``request`` and determines if it came from an authenticated user. +It does this by utilizing the :class:`pyramid.authentication.AuthTktCookieHelper` class which stores the :term:`identity` in a cryptographically-signed cookie. +If a ``request`` does contain an identity, then we perform a final check to determine if the user is valid in our current ``USERS`` store. -- If ``userid`` exists in the system, it will return either a sequence of group identifiers, or an empty sequence if the user is not a member of any groups. -- If the userid *does not* exist in the system, it will return ``None``. -For example: +Authorizing access to resources +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -- ``groupfinder('editor', request )`` returns ``['group:editor']``. -- ``groupfinder('viewer', request)`` returns ``[]``. -- ``groupfinder('admin', request)`` returns ``None``. +The ``MySecurityPolicy.permits`` method determines if the ``request`` is allowed a specific ``permission`` on the given ``context``. +This process is done in a few steps: -We will use ``groupfinder()`` as an :term:`authentication policy` "callback" that will provide the :term:`principal` or principals for a user. +- Convert the ``request`` into a list of :term:`principals <principal>` via the ``MySecurityPolicy.effective_principals`` method. -There are two helper methods that will help us later to authenticate users. +- Compare the list of principals to the ``context`` using the :class:`pyramid.authorization.ACLHelper`. + It will only allow access if it can find an :term:`ACE` that grants one of the principals the necessary permission. + +For our application we've defined a list of a few principals: + +- ``u:<userid>`` +- ``group:editor`` +- :attr:`pyramid.security.Authenticated` +- :attr:`pyramid.security.Everyone` + +Various wiki pages will grant some of these principals access to edit existing or add new pages. + +Finally there are two helper methods that will help us to authenticate users. The first is ``hash_password`` which takes a raw password and transforms it using bcrypt into an irreversible representation, a process known as "hashing". The second method, ``check_password``, will allow us to compare the hashed value of the submitted password against the hashed value of the password stored in the user's @@ -96,22 +128,52 @@ database. Here we use "dummy" data to represent user and groups sources. +Add new settings +~~~~~~~~~~~~~~~~ + +Our authentication policy is expecting a new setting, ``auth.secret``. Open +the file ``development.ini`` and add the highlighted line below: + +.. literalinclude:: src/authorization/development.ini + :lines: 19-21 + :emphasize-lines: 3 + :lineno-match: + :language: ini + +Best practices tell us to use a different secret in each environment. +Open ``production.ini`` and add a different secret: + +.. literalinclude:: src/authorization/production.ini + :lines: 17-19 + :emphasize-lines: 3 + :lineno-match: + :language: ini + +Edit ``testing.ini`` to add its unique secret: + +.. literalinclude:: src/authorization/testing.ini + :lines: 17-19 + :emphasize-lines: 3 + :lineno-match: + :language: ini + + Add an ACL ~~~~~~~~~~ Open ``tutorial/models/__init__.py`` and add the following import statement near the top: .. literalinclude:: src/authorization/tutorial/models/__init__.py - :lines: 4-8 + :lines: 4-7 :lineno-match: :language: python Add the following lines to the ``Wiki`` class: .. literalinclude:: src/authorization/tutorial/models/__init__.py - :lines: 9-13 + :pyobject: Wiki :lineno-match: - :emphasize-lines: 4-5 + :emphasize-lines: 4-7 :language: python We import :data:`~pyramid.security.Allow`, an action which means that @@ -137,72 +199,45 @@ We actually need only *one* ACL for the entire system, however, because our secu See :ref:`assigning_acls` for more information about what an :term:`ACL` represents. -Add authentication and authorization policies -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Open ``tutorial/__init__.py`` and add the highlighted import -statements: - -.. literalinclude:: src/authorization/tutorial/__init__.py - :lines: 1-8 - :linenos: - :emphasize-lines: 3-6,8 - :language: python - -Now add those policies to the configuration: - -.. literalinclude:: src/authorization/tutorial/__init__.py - :lines: 15-25 - :lineno-match: - :emphasize-lines: 4-6,8-9 - :language: python - -Only the highlighted lines need to be added. - -We enabled an ``AuthTktAuthenticationPolicy`` which is based in an auth ticket that may be included in the request. -We also enabled an ``ACLAuthorizationPolicy`` which uses an ACL to determine the *allow* or *deny* outcome for a view. - -Note that the :class:`pyramid.authentication.AuthTktAuthenticationPolicy` constructor accepts two arguments: ``secret`` and ``callback``. -``secret`` is a string representing an encryption key used by the "authentication ticket" machinery represented by this policy. -It is required. -The ``callback`` is the ``groupfinder()`` function that we created earlier. - - Add permission declarations ~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Open ``tutorial/views/default.py`` and add a ``permission='edit'`` parameter to the ``@view_config`` decorators for ``add_page()`` and ``edit_page()``: +Open ``tutorial/views/default.py``. +Add a ``permission='view'`` parameter to the ``@view_config`` decorators for ``view_wiki()`` and ``view_page()`` as follows: .. literalinclude:: src/authorization/tutorial/views/default.py - :lines: 49-51 - :emphasize-lines: 2-3 + :lines: 12 + :lineno-match: + :emphasize-lines: 1 :language: python .. literalinclude:: src/authorization/tutorial/views/default.py - :lines: 69-71 + :lines: 17-19 + :lineno-match: :emphasize-lines: 2-3 :language: python Only the highlighted lines, along with their preceding commas, need to be edited and added. -The result is that only users who possess the ``edit`` permission at the time of the request may invoke those two views. +This allows anyone to invoke these two views. -Add a ``permission='view'`` parameter to the ``@view_config`` decorator for -``view_wiki()`` and ``view_page()`` as follows: +Next add a ``permission='edit'`` parameter to the ``@view_config`` decorators for ``add_page()`` and ``edit_page()``: .. literalinclude:: src/authorization/tutorial/views/default.py - :lines: 21-22 - :emphasize-lines: 1-2 + :lines: 39-41 + :lineno-match: + :emphasize-lines: 2-3 :language: python .. literalinclude:: src/authorization/tutorial/views/default.py - :lines: 27-28 - :emphasize-lines: 1-2 + :lines: 58-60 + :lineno-match: + :emphasize-lines: 2-3 :language: python Only the highlighted lines, along with their preceding commas, need to be edited and added. -This allows anyone to invoke these two views. +The result is that only users who possess the ``edit`` permission at the time of the request may invoke those two views. We are done with the changes needed to control access. The changes that follow will add the login and logout feature. @@ -220,25 +255,15 @@ We will add a ``login`` view which renders a login form and processes the post f We will also add a ``logout`` view callable to our application and provide a link to it. This view will clear the credentials of the logged in user and redirect back to the front page. -Add the following import statements to the head of ``tutorial/views/default.py``: +Add a new file ``tutorial/views/auth.py`` with the following contents: -.. literalinclude:: src/authorization/tutorial/views/default.py - :lines: 4-15 - :emphasize-lines: 2-10,12 +.. literalinclude:: src/authorization/tutorial/views/auth.py + :lineno-match: :language: python -All the highlighted lines need to be added or edited. - :meth:`~pyramid.view.forbidden_view_config` will be used to customize the default 403 Forbidden page. :meth:`~pyramid.security.remember` and :meth:`~pyramid.security.forget` help to create and expire an auth ticket cookie. -Now add the ``login`` and ``logout`` views at the end of the file: - -.. literalinclude:: src/authorization/tutorial/views/default.py - :lines: 78- - :lineno-match: - :language: python - ``login()`` has two decorators: - A ``@view_config`` decorator which associates it with the ``login`` route and makes it visible when we visit ``/login``. @@ -263,41 +288,15 @@ Create ``tutorial/templates/login.pt`` with the following content: The above template is referenced in the login view that we just added in ``views.py``. -Return a ``logged_in`` flag to the renderer -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Open ``tutorial/views/default.py`` again. -Add a ``logged_in`` parameter to the return value of ``view_page()``, ``add_page()``, and ``edit_page()`` as follows: +Add "Login" and "Logout" links +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -.. literalinclude:: src/authorization/tutorial/views/default.py - :lines: 45-46 - :emphasize-lines: 1-2 - :language: python - -.. literalinclude:: src/authorization/tutorial/views/default.py - :lines: 65-66 - :emphasize-lines: 1-2 - :language: python +Open ``tutorial/templates/layout.pt`` and add the following code as indicated by the highlighted lines. -.. literalinclude:: src/authorization/tutorial/views/default.py - :lines: 77-79 - :emphasize-lines: 2-3 - :language: python - -Only the highlighted lines need to be added or edited. - -The :meth:`pyramid.request.Request.authenticated_userid` will be ``None`` if the user is not authenticated, or a ``userid`` if the user is authenticated. - - -Add a "Logout" link when logged in -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Open ``tutorial/templates/edit.pt`` and ``tutorial/templates/view.pt``. -Add the following code as indicated by the highlighted lines. - -.. literalinclude:: src/authorization/tutorial/templates/edit.pt - :lines: 4-8 - :emphasize-lines: 2-4 +.. literalinclude:: src/authorization/tutorial/templates/layout.pt + :lines: 34-43 + :lineno-match: + :emphasize-lines: 2-9 :language: html The attribute ``tal:condition="logged_in"`` will make the element be included when ``logged_in`` is any user id. @@ -306,54 +305,6 @@ The above element will not be included if ``logged_in`` is ``None``, such as whe a user is not authenticated. -Reviewing our changes ---------------------- - -Our ``tutorial/__init__.py`` will look like this when we are done: - -.. literalinclude:: src/authorization/tutorial/__init__.py - :linenos: - :emphasize-lines: 3-6,8,18-20,22-23 - :language: python - -Only the highlighted lines need to be added or edited. - -Our ``tutorial/models/__init__.py`` will look like this when we are done: - -.. literalinclude:: src/authorization/tutorial/models/__init__.py - :linenos: - :emphasize-lines: 4-8,12-13 - :language: python - -Only the highlighted lines need to be added or edited. - -Our ``tutorial/views/default.py`` will look like this when we are done: - -.. literalinclude:: src/authorization/tutorial/views/default.py - :linenos: - :emphasize-lines: 5-12,15,21-22,27-28,45-46,50-51,65-66,70-71,78- - :language: python - -Only the highlighted lines need to be added or edited. - -Our ``tutorial/templates/edit.pt`` template will look like this when we are done: - -.. literalinclude:: src/authorization/tutorial/templates/edit.pt - :linenos: - :emphasize-lines: 5-7 - :language: html - -Only the highlighted lines need to be added or edited. - -Our ``tutorial/templates/view.pt`` template will look like this when we are done: - -.. literalinclude:: src/authorization/tutorial/templates/view.pt - :linenos: - :emphasize-lines: 5-7 - :language: html - -Only the highlighted lines need to be added or edited. - Viewing the application in a browser ------------------------------------ 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/definingmodels.rst b/docs/tutorials/wiki/definingmodels.rst index 3a340e6f7..3b1e38c7d 100644 --- a/docs/tutorials/wiki/definingmodels.rst +++ b/docs/tutorials/wiki/definingmodels.rst @@ -43,8 +43,11 @@ Open ``tutorial/models/__init__.py`` file and edit it to look like the following .. literalinclude:: src/models/tutorial/models/__init__.py :linenos: + :emphasize-lines: 1,5-11,15-19 :language: python +The emphasized lines indicate changes, described as follows. + Remove the ``MyModel`` class from the generated ``models/__init__.py`` file. The ``MyModel`` class is only a sample and we're not going to use it. @@ -60,8 +63,9 @@ We will use this for a new ``Page`` class in a moment. Then we add a ``Wiki`` class. .. literalinclude:: src/models/tutorial/models/__init__.py - :lines: 4-6 + :pyobject: Wiki :lineno-match: + :emphasize-lines: 1-3 :language: py We want it to inherit from the :class:`persistent.mapping.PersistentMapping` class because it provides mapping behavior. @@ -74,8 +78,9 @@ The ``__name__`` of the root model is also always ``None``. Now we add a ``Page`` class. .. literalinclude:: src/models/tutorial/models/__init__.py - :lines: 8-10 + :pyobject: Page :lineno-match: + :emphasize-lines: 1-3 :language: py This class should inherit from the :class:`persistent.Persistent` class. @@ -91,9 +96,9 @@ We will create this function in the next chapter. As a last step, edit the ``appmaker`` function. .. literalinclude:: src/models/tutorial/models/__init__.py - :lines: 12-20 + :pyobject: appmaker :lineno-match: - :emphasize-lines: 4-8 + :emphasize-lines: 3-7 :language: py The :term:`root` :term:`resource` of our application is a Wiki instance. diff --git a/docs/tutorials/wiki/definingviews.rst b/docs/tutorials/wiki/definingviews.rst index 2e4d009a1..02d7bde9a 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. @@ -73,7 +74,7 @@ Success executing this command will end with a line to the console similar to th .. code-block:: text - Successfully installed docutils-0.15.2 tutorial + Successfully installed docutils-0.16 tutorial Adding view functions in the ``views`` package @@ -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. @@ -290,12 +291,11 @@ 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). +- We changed the ``title`` tag to use the ``name`` attribute of a ``page`` object, or if it does not exist then the page title (line 11). +- 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/index.rst b/docs/tutorials/wiki/index.rst index 7bd58656b..a45e7f3e2 100644 --- a/docs/tutorials/wiki/index.rst +++ b/docs/tutorials/wiki/index.rst @@ -10,7 +10,7 @@ finished, the developer will have created a basic Wiki application with authentication. For cut and paste purposes, the source code for all stages of this -tutorial can be browsed on GitHub at `GitHub <https://github.com/Pylons/pyramid/>`_ for a specific branch or version under ``docs/tutorials/wiki/src``, +tutorial can be browsed on `GitHub <https://github.com/Pylons/pyramid/>`_ for a specific branch or version under ``docs/tutorials/wiki/src``, which corresponds to the same location if you have Pyramid sources. .. toctree:: diff --git a/docs/tutorials/wiki/installation.rst b/docs/tutorials/wiki/installation.rst index 6088f577d..4de9b4b9c 100644 --- a/docs/tutorials/wiki/installation.rst +++ b/docs/tutorials/wiki/installation.rst @@ -188,15 +188,15 @@ The console will show ``pip`` checking for packages and installing missing packa Successfully installed BTrees-4.6.1 Chameleon-3.6.2 Mako-1.1.0 \ MarkupSafe-1.1.1 PasteDeploy-2.0.1 Pygments-2.5.2 WebTest-2.0.33 \ ZConfig-3.5.0 ZEO-5.2.1 ZODB-5.5.1 ZODB3-3.11.0 attrs-19.3.0 \ - beautifulsoup4-4.8.2 cffi-1.13.2 coverage-5.0.1 hupper-1.9.1 \ - importlib-metadata-1.3.0 more-itertools-8.0.2 packaging-19.2 \ + beautifulsoup4-4.8.2 cffi-1.13.2 coverage-5.0.3 hupper-1.9.1 \ + importlib-metadata-1.4.0 more-itertools-8.1.0 packaging-20.0 \ persistent-4.5.1 plaster-1.0 plaster-pastedeploy-0.7 pluggy-0.13.1 \ py-1.8.1 pycparser-2.19 pyparsing-2.4.6 pyramid-1.10.4 \ - pyramid-chameleon-0.3 pyramid-debugtoolbar-4.5.1 pyramid-mako-1.1.0 \ - pyramid-retry-2.1 pyramid-tm-2.3 pyramid-zodbconn-0.8.1 pytest-5.3.2 \ + pyramid-chameleon-0.3 pyramid-debugtoolbar-4.5.2 pyramid-mako-1.1.0 \ + pyramid-retry-2.1 pyramid-tm-2.4 pyramid-zodbconn-0.8.1 pytest-5.3.2 \ pytest-cov-2.8.1 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 wcwidth-0.1.7 webob-1.8.5 zc.lockfile-2.0 zdaemon-4.3 \ + waitress-1.4.2 wcwidth-0.1.8 webob-1.8.5 zc.lockfile-2.0 zdaemon-4.3 \ zipp-0.6.0 zodbpickle-2.0.0 zodburi-2.4.0 zope.deprecation-4.4.0 \ zope.interface-4.7.1 @@ -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 @@ -279,25 +279,25 @@ If successful, you will see output something like this: platform darwin -- Python 3.7.3, pytest-5.3.2, py-1.8.1, pluggy-0.13.1 rootdir: /filepath/tutorial, inifile: pytest.ini, testpaths: tutorial plugins: cov-2.8.1 - collected 2 items + collected 4 items - tutorial/tests.py .. [100%] + tests/test_functional.py .. [ 50%] + tests/test_views.py .. [100%] - - ---------- coverage: platform darwin, python 3.7.0-final-0 ----------- + ---------- coverage: platform darwin, python 3.7.3-final-0 ----------- Name Stmts Miss Cover Missing ----------------------------------------------------------- - tutorial/__init__.py 16 11 31% 7-8, 14-22 - tutorial/models/__init__.py 8 4 50% 9-12 - tutorial/pshell.py 6 6 0% 1-12 - tutorial/routes.py 2 2 0% 1-2 + tutorial/__init__.py 16 0 100% + tutorial/models/__init__.py 8 0 100% + tutorial/pshell.py 6 4 33% 5-12 + tutorial/routes.py 2 0 100% tutorial/views/__init__.py 0 0 100% tutorial/views/default.py 4 0 100% tutorial/views/notfound.py 4 0 100% ----------------------------------------------------------- - TOTAL 40 23 42% + TOTAL 40 4 90% - ===================== 2 passed in 0.55 seconds ======================= + ===================== 4 passed in 0.85 seconds ======================= Our package doesn't quite have 100% test coverage. @@ -309,23 +309,6 @@ Test and coverage cookiecutter defaults The Pyramid cookiecutter includes configuration defaults for ``pytest`` and test coverage. These configuration files are ``pytest.ini`` and ``.coveragerc``, located at the root of your package. -Without these defaults, we would need to specify the path to the module on which we want to run tests and coverage. - - -On Unix -^^^^^^^ - -.. code-block:: bash - - $VENV/bin/pytest --cov=tutorial tests.py -q - -On Windows -^^^^^^^^^^ - -.. code-block:: doscon - - %VENV%\Scripts\pytest --cov=tutorial tests -q - ``pytest`` follows :ref:`conventions for Python test discovery <pytest:test discovery>`. The configuration defaults from the cookiecutter tell ``pytest`` where to find the module on which we want to run tests and coverage. diff --git a/docs/tutorials/wiki/src/authorization/.gitignore b/docs/tutorials/wiki/src/authorization/.gitignore index 1853d983c..e9336274d 100644 --- a/docs/tutorials/wiki/src/authorization/.gitignore +++ b/docs/tutorials/wiki/src/authorization/.gitignore @@ -11,7 +11,7 @@ dist/ nosetests.xml env*/ tmp/ -Data.fs* +Data*.fs* *.sublime-project *.sublime-workspace .*.sw? @@ -19,3 +19,4 @@ Data.fs* .DS_Store coverage test +*.sqlite diff --git a/docs/tutorials/wiki/src/authorization/development.ini b/docs/tutorials/wiki/src/authorization/development.ini index 228f18f36..e8aef6b43 100644 --- a/docs/tutorials/wiki/src/authorization/development.ini +++ b/docs/tutorials/wiki/src/authorization/development.ini @@ -18,6 +18,8 @@ zodbconn.uri = file://%(here)s/Data.fs?connection_cache_size=20000 retry.attempts = 3 +auth.secret = seekrit + # By default, the toolbar only appears for clients from IP addresses # '127.0.0.1' and '::1'. # debugtoolbar.hosts = 127.0.0.1 ::1 diff --git a/docs/tutorials/wiki/src/authorization/production.ini b/docs/tutorials/wiki/src/authorization/production.ini index 46b1e331b..35ef6aabe 100644 --- a/docs/tutorials/wiki/src/authorization/production.ini +++ b/docs/tutorials/wiki/src/authorization/production.ini @@ -16,6 +16,8 @@ zodbconn.uri = file://%(here)s/Data.fs?connection_cache_size=20000 retry.attempts = 3 +auth.secret = real-seekrit + [pshell] setup = tutorial.pshell.setup diff --git a/docs/tutorials/wiki/src/authorization/setup.py b/docs/tutorials/wiki/src/authorization/setup.py index f19d643e6..cdfa18e09 100644 --- a/docs/tutorials/wiki/src/authorization/setup.py +++ b/docs/tutorials/wiki/src/authorization/setup.py @@ -9,6 +9,8 @@ with open(os.path.join(here, 'CHANGES.txt')) as f: CHANGES = f.read() requires = [ + 'bcrypt', + 'docutils', 'plaster_pastedeploy', 'pyramid', 'pyramid_chameleon', @@ -19,8 +21,6 @@ requires = [ 'pyramid_zodbconn', 'transaction', 'ZODB3', - 'docutils', - 'bcrypt', ] tests_require = [ diff --git a/docs/tutorials/wiki/src/authorization/testing.ini b/docs/tutorials/wiki/src/authorization/testing.ini new file mode 100644 index 000000000..81193b35a --- /dev/null +++ b/docs/tutorials/wiki/src/authorization/testing.ini @@ -0,0 +1,62 @@ +### +# 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 + +auth.secret = testing-seekrit + +[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/authorization/tests/conftest.py b/docs/tutorials/wiki/src/authorization/tests/conftest.py new file mode 100644 index 000000000..6a702ae12 --- /dev/null +++ b/docs/tutorials/wiki/src/authorization/tests/conftest.py @@ -0,0 +1,84 @@ +import os +from pyramid.paster import get_appsettings +from pyramid.scripting import prepare +from pyramid.testing import DummyRequest +import pytest +import transaction +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 tm(): + tm = transaction.manager + tm.begin() + tm.doom() + + yield tm + + tm.abort() + +@pytest.fixture +def testapp(app, tm): + testapp = webtest.TestApp(app, extra_environ={ + 'HTTP_HOST': 'example.com', + 'tm.active': True, + 'tm.manager': tm, + }) + + return testapp + +@pytest.fixture +def app_request(app, tm): + """ + 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' + request.tm = tm + + yield request + env['closer']() + +@pytest.fixture +def dummy_request(app, tm): + """ + 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.tm = tm + + return request diff --git a/docs/tutorials/wiki/src/authorization/tests/test_functional.py b/docs/tutorials/wiki/src/authorization/tests/test_functional.py new file mode 100644 index 000000000..bac5d63f4 --- /dev/null +++ b/docs/tutorials/wiki/src/authorization/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/authorization/tests/test_it.py b/docs/tutorials/wiki/src/authorization/tests/test_it.py deleted file mode 100644 index 6c72bcc62..000000000 --- a/docs/tutorials/wiki/src/authorization/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/authorization/tests/test_views.py b/docs/tutorials/wiki/src/authorization/tests/test_views.py new file mode 100644 index 000000000..2b4201955 --- /dev/null +++ b/docs/tutorials/wiki/src/authorization/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/authorization/tutorial/__init__.py b/docs/tutorials/wiki/src/authorization/tutorial/__init__.py index 935a5d6d2..2706cc184 100644 --- a/docs/tutorials/wiki/src/authorization/tutorial/__init__.py +++ b/docs/tutorials/wiki/src/authorization/tutorial/__init__.py @@ -1,11 +1,8 @@ from pyramid.config import Configurator from pyramid_zodbconn import get_connection -from pyramid.authentication import AuthTktAuthenticationPolicy -from pyramid.authorization import ACLAuthorizationPolicy - from .models import appmaker -from .security import groupfinder + def root_factory(request): conn = get_connection(request) @@ -15,17 +12,13 @@ def root_factory(request): def main(global_config, **settings): """ This function returns a Pyramid WSGI application. """ - authn_policy = AuthTktAuthenticationPolicy( - 'sosecret', callback=groupfinder, hashalg='sha512') - authz_policy = ACLAuthorizationPolicy() with Configurator(settings=settings) as config: - config.set_authentication_policy(authn_policy) - config.set_authorization_policy(authz_policy) + 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.include('.security') + config.set_root_factory(root_factory) config.scan() return config.make_wsgi_app() diff --git a/docs/tutorials/wiki/src/authorization/tutorial/models/__init__.py b/docs/tutorials/wiki/src/authorization/tutorial/models/__init__.py index ebd70e912..64ae4bf5c 100644 --- a/docs/tutorials/wiki/src/authorization/tutorial/models/__init__.py +++ b/docs/tutorials/wiki/src/authorization/tutorial/models/__init__.py @@ -4,13 +4,15 @@ from persistent.mapping import PersistentMapping from pyramid.security import ( Allow, Everyone, - ) +) class Wiki(PersistentMapping): __name__ = None __parent__ = None - __acl__ = [ (Allow, Everyone, 'view'), - (Allow, 'group:editors', 'edit') ] + __acl__ = [ + (Allow, Everyone, 'view'), + (Allow, 'group:editors', 'edit'), + ] class Page(Persistent): def __init__(self, data): diff --git a/docs/tutorials/wiki/src/authorization/tutorial/security.py b/docs/tutorials/wiki/src/authorization/tutorial/security.py index cbb3acd5d..9f51aa54c 100644 --- a/docs/tutorials/wiki/src/authorization/tutorial/security.py +++ b/docs/tutorials/wiki/src/authorization/tutorial/security.py @@ -1,4 +1,10 @@ import bcrypt +from pyramid.authentication import AuthTktCookieHelper +from pyramid.authorization import ACLHelper +from pyramid.security import ( + Authenticated, + Everyone, +) def hash_password(pw): @@ -11,10 +17,47 @@ def check_password(expected_hash, pw): return bcrypt.checkpw(pw.encode('utf-8'), expected_hash.encode('utf-8')) return False -USERS = {'editor': hash_password('editor'), - 'viewer': hash_password('viewer')} -GROUPS = {'editor':['group:editors']} +USERS = { + 'editor': hash_password('editor'), + 'viewer': hash_password('viewer'), +} +GROUPS = {'editor': ['group:editors']} -def groupfinder(userid, request): - if userid in USERS: - return GROUPS.get(userid, []) +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 not None and identity['userid'] in USERS: + return identity + + def authenticated_userid(self, request): + identity = self.authenticated_identity(request) + if identity is not None: + return identity['userid'] + + 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] + identity = self.authenticated_identity(request) + if identity is not None: + principals.append(Authenticated) + principals.append('u:' + identity['userid']) + principals.extend(GROUPS.get(identity['userid'], [])) + return principals + +def includeme(config): + settings = config.get_settings() + + config.set_security_policy(MySecurityPolicy(settings['auth.secret'])) diff --git a/docs/tutorials/wiki/src/authorization/tutorial/templates/edit.pt b/docs/tutorials/wiki/src/authorization/tutorial/templates/edit.pt index 6438b1569..488e7a6af 100644 --- a/docs/tutorials/wiki/src/authorization/tutorial/templates/edit.pt +++ b/docs/tutorials/wiki/src/authorization/tutorial/templates/edit.pt @@ -2,9 +2,6 @@ <div metal:fill-slot="content"> <div class="content"> - <p tal:condition="logged_in" class="pull-right"> - <a href="${request.application_url}/logout">Logout</a> - </p> <p> Editing <strong><span tal:replace="page.__name__"> Page Name Goes Here</span></strong> diff --git a/docs/tutorials/wiki/src/authorization/tutorial/templates/layout.pt b/docs/tutorials/wiki/src/authorization/tutorial/templates/layout.pt index 06a3c8157..61042da24 100644 --- a/docs/tutorials/wiki/src/authorization/tutorial/templates/layout.pt +++ b/docs/tutorials/wiki/src/authorization/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)</title> + <title><span tal:replace="page.__name__ | title"></span> - Pyramid tutorial wiki (based on TurboGears 20-Minute Wiki)</title> <!-- Bootstrap core CSS --> <link rel="stylesheet" href="//maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous"> @@ -33,6 +32,14 @@ <img class="logo img-responsive" src="${request.static_url('tutorial:static/pyramid.png')}" alt="pyramid web framework"> </div> <div class="col-md-10"> + <div class="content"> + <p tal:condition="request.authenticated_userid is None" class="pull-right"> + <a href="${request.application_url}/login">Login</a> + </p> + <p tal:condition="request.authenticated_userid is not None" class="pull-right"> + <a href="${request.application_url}/logout">Logout</a> + </p> + </div> <div metal:define-slot="content">No content</div> <div class="content"> <p>You can return to the @@ -42,6 +49,15 @@ </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 </div> diff --git a/docs/tutorials/wiki/src/authorization/tutorial/templates/view.pt b/docs/tutorials/wiki/src/authorization/tutorial/templates/view.pt index 911ab0c99..b8a6fbbaf 100644 --- a/docs/tutorials/wiki/src/authorization/tutorial/templates/view.pt +++ b/docs/tutorials/wiki/src/authorization/tutorial/templates/view.pt @@ -2,9 +2,6 @@ <div metal:fill-slot="content"> <div class="content"> - <p tal:condition="logged_in" class="pull-right"> - <a href="${request.application_url}/logout">Logout</a> - </p> <div tal:replace="structure page_text"> Page text goes here. </div> diff --git a/docs/tutorials/wiki/src/authorization/tutorial/views/auth.py b/docs/tutorials/wiki/src/authorization/tutorial/views/auth.py new file mode 100644 index 000000000..5062779a6 --- /dev/null +++ b/docs/tutorials/wiki/src/authorization/tutorial/views/auth.py @@ -0,0 +1,51 @@ +from pyramid.httpexceptions import HTTPSeeOther +from pyramid.security import ( + forget, + remember, +) +from pyramid.view import ( + forbidden_view_config, + view_config, +) + +from ..security import check_password, USERS + + +@view_config(context='..models.Wiki', name='login', + renderer='tutorial:templates/login.pt') +@forbidden_view_config(renderer='tutorial:templates/login.pt') +def login(request): + login_url = request.resource_url(request.root, 'login') + referrer = request.url + if referrer == login_url: + referrer = '/' # never use the login form itself as came_from + came_from = request.params.get('came_from', referrer) + message = '' + login = '' + password = '' + if 'form.submitted' in request.params: + login = request.params['login'] + password = request.params['password'] + if check_password(USERS.get(login), password): + headers = remember(request, login) + return HTTPSeeOther(location=came_from, headers=headers) + message = 'Failed login' + request.response.status = 400 + + return dict( + message=message, + url=login_url, + came_from=came_from, + login=login, + password=password, + title='Login', + ) + + +@view_config(context='..models.Wiki', name='logout') +def logout(request): + headers = forget(request) + return HTTPSeeOther( + location=request.resource_url(request.context), + headers=headers, + ) diff --git a/docs/tutorials/wiki/src/authorization/tutorial/views/default.py b/docs/tutorials/wiki/src/authorization/tutorial/views/default.py index 7ba99c65b..5bb21fbcd 100644 --- a/docs/tutorials/wiki/src/authorization/tutorial/views/default.py +++ b/docs/tutorials/wiki/src/authorization/tutorial/views/default.py @@ -1,30 +1,21 @@ from docutils.core import publish_parts +from pyramid.httpexceptions import HTTPSeeOther +from pyramid.view import view_config import re -from pyramid.httpexceptions import HTTPFound -from pyramid.security import ( - forget, - remember, -) -from pyramid.view import ( - forbidden_view_config, - view_config, - ) - from ..models import Page -from ..security import check_password, USERS + # regular expression used to find WikiWords wikiwords = re.compile(r"\b([A-Z]\w+[A-Z]+\w+)") - -@view_config(context='..models.Wiki', - permission='view') +@view_config(context='..models.Wiki', permission='view') def view_wiki(context, request): - return HTTPFound(location=request.resource_url(context, 'FrontPage')) + return HTTPSeeOther(location=request.resource_url(context, 'FrontPage')) -@view_config(context='..models.Page', renderer='tutorial:templates/view.pt', +@view_config(context='..models.Page', + renderer='tutorial:templates/view.pt', permission='view') def view_page(context, request): wiki = context.__parent__ @@ -42,8 +33,7 @@ def view_page(context, request): page_text = publish_parts(context.data, writer_name='html')['html_body'] page_text = wikiwords.sub(check, page_text) edit_url = request.resource_url(context, 'edit_page') - return dict(page=context, page_text=page_text, edit_url=edit_url, - logged_in=request.authenticated_userid) + return dict(page=context, page_text=page_text, edit_url=edit_url) @view_config(name='add_page', context='..models.Wiki', @@ -57,13 +47,12 @@ def add_page(context, request): page.__name__ = pagename page.__parent__ = context context[pagename] = page - return HTTPFound(location=request.resource_url(page)) + return HTTPSeeOther(location=request.resource_url(page)) save_url = request.resource_url(context, 'add_page', pagename) page = Page('') page.__name__ = pagename page.__parent__ = context - return dict(page=page, save_url=save_url, - logged_in=request.authenticated_userid) + return dict(page=page, save_url=save_url) @view_config(name='edit_page', context='..models.Page', @@ -72,46 +61,9 @@ def add_page(context, request): def edit_page(context, request): if 'form.submitted' in request.params: context.data = request.params['body'] - return HTTPFound(location=request.resource_url(context)) - - return dict(page=context, - save_url=request.resource_url(context, 'edit_page'), - logged_in=request.authenticated_userid) - - -@view_config(context='..models.Wiki', name='login', - renderer='tutorial:templates/login.pt') -@forbidden_view_config(renderer='tutorial:templates/login.pt') -def login(request): - login_url = request.resource_url(request.context, 'login') - referrer = request.url - if referrer == login_url: - referrer = '/' # never use the login form itself as came_from - came_from = request.params.get('came_from', referrer) - message = '' - login = '' - password = '' - if 'form.submitted' in request.params: - login = request.params['login'] - password = request.params['password'] - if check_password(USERS.get(login), password): - headers = remember(request, login) - return HTTPFound(location=came_from, - headers=headers) - message = 'Failed login' + return HTTPSeeOther(location=request.resource_url(context)) return dict( - message=message, - url=request.application_url + '/login', - came_from=came_from, - login=login, - password=password, - title='Login', + page=context, + save_url=request.resource_url(context, 'edit_page'), ) - - -@view_config(context='..models.Wiki', name='logout') -def logout(request): - headers = forget(request) - return HTTPFound(location=request.resource_url(request.context), - headers=headers) diff --git a/docs/tutorials/wiki/src/basiclayout/.gitignore b/docs/tutorials/wiki/src/basiclayout/.gitignore index 1853d983c..e9336274d 100644 --- a/docs/tutorials/wiki/src/basiclayout/.gitignore +++ b/docs/tutorials/wiki/src/basiclayout/.gitignore @@ -11,7 +11,7 @@ dist/ nosetests.xml env*/ tmp/ -Data.fs* +Data*.fs* *.sublime-project *.sublime-workspace .*.sw? @@ -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..6a702ae12 --- /dev/null +++ b/docs/tutorials/wiki/src/basiclayout/tests/conftest.py @@ -0,0 +1,84 @@ +import os +from pyramid.paster import get_appsettings +from pyramid.scripting import prepare +from pyramid.testing import DummyRequest +import pytest +import transaction +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 tm(): + tm = transaction.manager + tm.begin() + tm.doom() + + yield tm + + tm.abort() + +@pytest.fixture +def testapp(app, tm): + testapp = webtest.TestApp(app, extra_environ={ + 'HTTP_HOST': 'example.com', + 'tm.active': True, + 'tm.manager': tm, + }) + + return testapp + +@pytest.fixture +def app_request(app, tm): + """ + 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' + request.tm = tm + + yield request + env['closer']() + +@pytest.fixture +def dummy_request(app, tm): + """ + 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.tm = tm + + 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..e9336274d 100644 --- a/docs/tutorials/wiki/src/installation/.gitignore +++ b/docs/tutorials/wiki/src/installation/.gitignore @@ -11,7 +11,7 @@ dist/ nosetests.xml env*/ tmp/ -Data.fs* +Data*.fs* *.sublime-project *.sublime-workspace .*.sw? @@ -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..6a702ae12 --- /dev/null +++ b/docs/tutorials/wiki/src/installation/tests/conftest.py @@ -0,0 +1,84 @@ +import os +from pyramid.paster import get_appsettings +from pyramid.scripting import prepare +from pyramid.testing import DummyRequest +import pytest +import transaction +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 tm(): + tm = transaction.manager + tm.begin() + tm.doom() + + yield tm + + tm.abort() + +@pytest.fixture +def testapp(app, tm): + testapp = webtest.TestApp(app, extra_environ={ + 'HTTP_HOST': 'example.com', + 'tm.active': True, + 'tm.manager': tm, + }) + + return testapp + +@pytest.fixture +def app_request(app, tm): + """ + 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' + request.tm = tm + + yield request + env['closer']() + +@pytest.fixture +def dummy_request(app, tm): + """ + 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.tm = tm + + 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..e9336274d 100644 --- a/docs/tutorials/wiki/src/models/.gitignore +++ b/docs/tutorials/wiki/src/models/.gitignore @@ -11,7 +11,7 @@ dist/ nosetests.xml env*/ tmp/ -Data.fs* +Data*.fs* *.sublime-project *.sublime-workspace .*.sw? @@ -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..6a702ae12 --- /dev/null +++ b/docs/tutorials/wiki/src/models/tests/conftest.py @@ -0,0 +1,84 @@ +import os +from pyramid.paster import get_appsettings +from pyramid.scripting import prepare +from pyramid.testing import DummyRequest +import pytest +import transaction +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 tm(): + tm = transaction.manager + tm.begin() + tm.doom() + + yield tm + + tm.abort() + +@pytest.fixture +def testapp(app, tm): + testapp = webtest.TestApp(app, extra_environ={ + 'HTTP_HOST': 'example.com', + 'tm.active': True, + 'tm.manager': tm, + }) + + return testapp + +@pytest.fixture +def app_request(app, tm): + """ + 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' + request.tm = tm + + yield request + env['closer']() + +@pytest.fixture +def dummy_request(app, tm): + """ + 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.tm = tm + + 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/models/tutorial/models/__init__.py b/docs/tutorials/wiki/src/models/tutorial/models/__init__.py index 7c6597afa..53e105a8e 100644 --- a/docs/tutorials/wiki/src/models/tutorial/models/__init__.py +++ b/docs/tutorials/wiki/src/models/tutorial/models/__init__.py @@ -1,6 +1,7 @@ from persistent import Persistent from persistent.mapping import PersistentMapping + class Wiki(PersistentMapping): __name__ = None __parent__ = None diff --git a/docs/tutorials/wiki/src/tests/.gitignore b/docs/tutorials/wiki/src/tests/.gitignore index 1853d983c..e9336274d 100644 --- a/docs/tutorials/wiki/src/tests/.gitignore +++ b/docs/tutorials/wiki/src/tests/.gitignore @@ -11,7 +11,7 @@ dist/ nosetests.xml env*/ tmp/ -Data.fs* +Data*.fs* *.sublime-project *.sublime-workspace .*.sw? @@ -19,3 +19,4 @@ Data.fs* .DS_Store coverage test +*.sqlite diff --git a/docs/tutorials/wiki/src/tests/development.ini b/docs/tutorials/wiki/src/tests/development.ini index 228f18f36..e8aef6b43 100644 --- a/docs/tutorials/wiki/src/tests/development.ini +++ b/docs/tutorials/wiki/src/tests/development.ini @@ -18,6 +18,8 @@ zodbconn.uri = file://%(here)s/Data.fs?connection_cache_size=20000 retry.attempts = 3 +auth.secret = seekrit + # By default, the toolbar only appears for clients from IP addresses # '127.0.0.1' and '::1'. # debugtoolbar.hosts = 127.0.0.1 ::1 diff --git a/docs/tutorials/wiki/src/tests/production.ini b/docs/tutorials/wiki/src/tests/production.ini index 46b1e331b..35ef6aabe 100644 --- a/docs/tutorials/wiki/src/tests/production.ini +++ b/docs/tutorials/wiki/src/tests/production.ini @@ -16,6 +16,8 @@ zodbconn.uri = file://%(here)s/Data.fs?connection_cache_size=20000 retry.attempts = 3 +auth.secret = real-seekrit + [pshell] setup = tutorial.pshell.setup diff --git a/docs/tutorials/wiki/src/tests/setup.py b/docs/tutorials/wiki/src/tests/setup.py index f19d643e6..cdfa18e09 100644 --- a/docs/tutorials/wiki/src/tests/setup.py +++ b/docs/tutorials/wiki/src/tests/setup.py @@ -9,6 +9,8 @@ with open(os.path.join(here, 'CHANGES.txt')) as f: CHANGES = f.read() requires = [ + 'bcrypt', + 'docutils', 'plaster_pastedeploy', 'pyramid', 'pyramid_chameleon', @@ -19,8 +21,6 @@ requires = [ 'pyramid_zodbconn', 'transaction', 'ZODB3', - 'docutils', - 'bcrypt', ] tests_require = [ diff --git a/docs/tutorials/wiki/src/tests/testing.ini b/docs/tutorials/wiki/src/tests/testing.ini new file mode 100644 index 000000000..81193b35a --- /dev/null +++ b/docs/tutorials/wiki/src/tests/testing.ini @@ -0,0 +1,62 @@ +### +# 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 + +auth.secret = testing-seekrit + +[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/tests/tests/conftest.py b/docs/tutorials/wiki/src/tests/tests/conftest.py new file mode 100644 index 000000000..6a702ae12 --- /dev/null +++ b/docs/tutorials/wiki/src/tests/tests/conftest.py @@ -0,0 +1,84 @@ +import os +from pyramid.paster import get_appsettings +from pyramid.scripting import prepare +from pyramid.testing import DummyRequest +import pytest +import transaction +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 tm(): + tm = transaction.manager + tm.begin() + tm.doom() + + yield tm + + tm.abort() + +@pytest.fixture +def testapp(app, tm): + testapp = webtest.TestApp(app, extra_environ={ + 'HTTP_HOST': 'example.com', + 'tm.active': True, + 'tm.manager': tm, + }) + + return testapp + +@pytest.fixture +def app_request(app, tm): + """ + 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' + request.tm = tm + + yield request + env['closer']() + +@pytest.fixture +def dummy_request(app, tm): + """ + 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.tm = tm + + return request diff --git a/docs/tutorials/wiki/src/tests/tests/test_functional.py b/docs/tutorials/wiki/src/tests/tests/test_functional.py new file mode 100644 index 000000000..25555713c --- /dev/null +++ b/docs/tutorials/wiki/src/tests/tests/test_functional.py @@ -0,0 +1,80 @@ +viewer_login = ( + '/login?login=viewer&password=viewer' + '&came_from=FrontPage&form.submitted=Login' +) +viewer_wrong_login = ( + '/login?login=viewer&password=incorrect' + '&came_from=FrontPage&form.submitted=Login' +) +editor_login = ( + '/login?login=editor&password=editor' + '&came_from=FrontPage&form.submitted=Login' +) + +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'Not Found' in res.body + +def test_referrer_is_login(testapp): + res = testapp.get('/login', status=200) + assert b'name="came_from" value="/"' in res.body + +def test_successful_log_in(testapp): + res = testapp.get(viewer_login, status=303) + assert res.location == 'http://example.com/FrontPage' + +def test_failed_log_in(testapp): + res = testapp.get(viewer_wrong_login, status=400) + assert b'login' in res.body + +def test_logout_link_present_when_logged_in(testapp): + res = testapp.get(viewer_login, status=303) + res = testapp.get('/FrontPage', status=200) + assert b'Logout' in res.body + +def test_logout_link_not_present_after_logged_out(testapp): + res = testapp.get(viewer_login, status=303) + res = testapp.get('/FrontPage', status=200) + res = testapp.get('/logout', status=303) + assert b'Logout' not in res.body + +def test_anonymous_user_cannot_edit(testapp): + res = testapp.get('/FrontPage/edit_page', status=200) + assert b'Login' in res.body + +def test_anonymous_user_cannot_add(testapp): + res = testapp.get('/add_page/NewPage', status=200) + assert b'Login' in res.body + +def test_viewer_user_cannot_edit(testapp): + res = testapp.get(viewer_login, status=303) + res = testapp.get('/FrontPage/edit_page', status=200) + assert b'Login' in res.body + +def test_viewer_user_cannot_add(testapp): + res = testapp.get(viewer_login, status=303) + res = testapp.get('/add_page/NewPage', status=200) + assert b'Login' in res.body + +def test_editors_member_user_can_edit(testapp): + res = testapp.get(editor_login, status=303) + res = testapp.get('/FrontPage/edit_page', status=200) + assert b'Editing' in res.body + +def test_editors_member_user_can_add(testapp): + res = testapp.get(editor_login, status=303) + res = testapp.get('/add_page/NewPage', status=200) + assert b'Editing' in res.body + +def test_editors_member_user_can_view(testapp): + res = testapp.get(editor_login, status=303) + res = testapp.get('/FrontPage', status=200) + assert b'FrontPage' in res.body diff --git a/docs/tutorials/wiki/src/tests/tests/test_it.py b/docs/tutorials/wiki/src/tests/tests/test_it.py deleted file mode 100644 index e45380e6f..000000000 --- a/docs/tutorials/wiki/src/tests/tests/test_it.py +++ /dev/null @@ -1,232 +0,0 @@ -import unittest - -from pyramid import testing - -class PageModelTests(unittest.TestCase): - - def _getTargetClass(self): - from tutorial.models import Page - return Page - - def _makeOne(self, data='some data'): - return self._getTargetClass()(data=data) - - def test_constructor(self): - instance = self._makeOne() - self.assertEqual(instance.data, 'some data') - -class WikiModelTests(unittest.TestCase): - - def _getTargetClass(self): - from tutorial.models import Wiki - return Wiki - - def _makeOne(self): - return self._getTargetClass()() - - def test_it(self): - wiki = self._makeOne() - self.assertEqual(wiki.__parent__, None) - self.assertEqual(wiki.__name__, None) - -class AppmakerTests(unittest.TestCase): - - def _callFUT(self, zodb_root): - from tutorial.models import appmaker - return appmaker(zodb_root) - - def test_it(self): - root = {} - self._callFUT(root) - self.assertEqual(root['app_root']['FrontPage'].data, - 'This is the front page') - -class ViewWikiTests(unittest.TestCase): - def test_it(self): - from tutorial.views.default import view_wiki - context = testing.DummyResource() - request = testing.DummyRequest() - response = view_wiki(context, request) - self.assertEqual(response.location, 'http://example.com/FrontPage') - -class ViewPageTests(unittest.TestCase): - def _callFUT(self, context, request): - from tutorial.views.default import view_page - return view_page(context, request) - - def test_it(self): - wiki = testing.DummyResource() - wiki['IDoExist'] = testing.DummyResource() - context = testing.DummyResource(data='Hello CruelWorld IDoExist') - context.__parent__ = wiki - context.__name__ = 'thepage' - request = testing.DummyRequest() - info = self._callFUT(context, request) - self.assertEqual(info['page'], context) - self.assertEqual( - info['page_text'], - '<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/thepage/edit_page') - - -class AddPageTests(unittest.TestCase): - def _callFUT(self, context, request): - from tutorial.views.default import add_page - return add_page(context, request) - - def test_it_notsubmitted(self): - context = testing.DummyResource() - request = testing.DummyRequest() - request.subpath = ['AnotherPage'] - info = self._callFUT(context, request) - self.assertEqual(info['page'].data,'') - self.assertEqual( - info['save_url'], - request.resource_url(context, 'add_page', 'AnotherPage')) - - def test_it_submitted(self): - context = testing.DummyResource() - request = testing.DummyRequest({'form.submitted':True, - 'body':'Hello yo!'}) - request.subpath = ['AnotherPage'] - self._callFUT(context, request) - page = context['AnotherPage'] - self.assertEqual(page.data, 'Hello yo!') - self.assertEqual(page.__name__, 'AnotherPage') - self.assertEqual(page.__parent__, context) - -class EditPageTests(unittest.TestCase): - def _callFUT(self, context, request): - from tutorial.views.default import edit_page - return edit_page(context, request) - - def test_it_notsubmitted(self): - context = testing.DummyResource() - request = testing.DummyRequest() - info = self._callFUT(context, request) - self.assertEqual(info['page'], context) - self.assertEqual(info['save_url'], - request.resource_url(context, 'edit_page')) - - def test_it_submitted(self): - context = testing.DummyResource() - request = testing.DummyRequest({'form.submitted':True, - 'body':'Hello yo!'}) - response = self._callFUT(context, request) - self.assertEqual(response.location, 'http://example.com/') - self.assertEqual(context.data, 'Hello yo!') - -class SecurityTests(unittest.TestCase): - def test_hashing(self): - from tutorial.security import hash_password, check_password - password = 'secretpassword' - hashed_password = hash_password(password) - self.assertTrue(check_password(hashed_password, password)) - - self.assertFalse(check_password(hashed_password, 'attackerpassword')) - - self.assertFalse(check_password(None, password)) - -class FunctionalTests(unittest.TestCase): - - viewer_login = '/login?login=viewer&password=viewer' \ - '&came_from=FrontPage&form.submitted=Login' - viewer_wrong_login = '/login?login=viewer&password=incorrect' \ - '&came_from=FrontPage&form.submitted=Login' - editor_login = '/login?login=editor&password=editor' \ - '&came_from=FrontPage&form.submitted=Login' - - def setUp(self): - import tempfile - import os.path - from tutorial import main - self.tmpdir = tempfile.mkdtemp() - - dbpath = os.path.join( self.tmpdir, 'test.db') - uri = 'file://' + dbpath - settings = { 'zodbconn.uri' : uri , - 'pyramid.includes': ['pyramid_zodbconn', 'pyramid_tm'] } - - app = main({}, **settings) - self.db = app.registry._zodb_databases[''] - from webtest import TestApp - self.testapp = TestApp(app) - - def tearDown(self): - import shutil - self.db.close() - shutil.rmtree( self.tmpdir ) - - 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): - res = self.testapp.get('/SomePage', status=404) - self.assertTrue(b'Not Found' in res.body) - - def test_referrer_is_login(self): - res = self.testapp.get('/login', status=200) - self.assertTrue(b'name="came_from" value="/"' in res.body) - - def test_successful_log_in(self): - res = self.testapp.get( self.viewer_login, status=302) - self.assertEqual(res.location, 'http://localhost/FrontPage') - - def test_failed_log_in(self): - res = self.testapp.get( self.viewer_wrong_login, status=200) - self.assertTrue(b'login' in res.body) - - def test_logout_link_present_when_logged_in(self): - res = self.testapp.get( self.viewer_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): - res = self.testapp.get( self.viewer_login, status=302) - res = 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=200) - self.assertTrue(b'Login' in res.body) - - def test_anonymous_user_cannot_add(self): - res = self.testapp.get('/add_page/NewPage', status=200) - self.assertTrue(b'Login' in res.body) - - def test_viewer_user_cannot_edit(self): - res = self.testapp.get( self.viewer_login, status=302) - res = self.testapp.get('/FrontPage/edit_page', status=200) - self.assertTrue(b'Login' in res.body) - - def test_viewer_user_cannot_add(self): - res = self.testapp.get( self.viewer_login, status=302) - res = self.testapp.get('/add_page/NewPage', status=200) - self.assertTrue(b'Login' in res.body) - - def test_editors_member_user_can_edit(self): - res = 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): - res = 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): - res = self.testapp.get( self.editor_login, status=302) - res = self.testapp.get('/FrontPage', status=200) - self.assertTrue(b'FrontPage' in res.body) diff --git a/docs/tutorials/wiki/src/tests/tests/test_models.py b/docs/tutorials/wiki/src/tests/tests/test_models.py new file mode 100644 index 000000000..03ba0f669 --- /dev/null +++ b/docs/tutorials/wiki/src/tests/tests/test_models.py @@ -0,0 +1,24 @@ +from tutorial import models + +def test_page_model(): + instance = models.Page(data='some data') + assert instance.data == 'some data' + +def test_wiki_model(): + wiki = models.Wiki() + assert wiki.__parent__ is None + assert wiki.__name__ is None + +def test_appmaker(): + root = {} + models.appmaker(root) + assert root['app_root']['FrontPage'].data == 'This is the front page' + +def test_password_hashing(): + from tutorial.security import hash_password, check_password + + password = 'secretpassword' + hashed_password = hash_password(password) + assert check_password(hashed_password, password) + assert not check_password(hashed_password, 'attackerpassword') + assert not check_password(None, password) diff --git a/docs/tutorials/wiki/src/tests/tests/test_views.py b/docs/tutorials/wiki/src/tests/tests/test_views.py new file mode 100644 index 000000000..bb8025b5c --- /dev/null +++ b/docs/tutorials/wiki/src/tests/tests/test_views.py @@ -0,0 +1,83 @@ +from pyramid import testing + + +class Test_view_wiki: + def test_it_redirects_to_front_page(self): + from tutorial.views.default import view_wiki + context = testing.DummyResource() + request = testing.DummyRequest() + response = view_wiki(context, request) + assert response.location == 'http://example.com/FrontPage' + +class Test_view_page: + def _callFUT(self, context, request): + from tutorial.views.default import view_page + return view_page(context, request) + + def test_it(self): + wiki = testing.DummyResource() + wiki['IDoExist'] = testing.DummyResource() + context = testing.DummyResource(data='Hello CruelWorld IDoExist') + context.__parent__ = wiki + context.__name__ = 'thepage' + request = testing.DummyRequest() + info = self._callFUT(context, request) + assert info['page'] == context + assert info['page_text'] == ( + '<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') + assert info['edit_url'] == 'http://example.com/thepage/edit_page' + + +class Test_add_page: + def _callFUT(self, context, request): + from tutorial.views.default import add_page + return add_page(context, request) + + def test_it_notsubmitted(self): + context = testing.DummyResource() + request = testing.DummyRequest() + request.subpath = ['AnotherPage'] + info = self._callFUT(context, request) + assert info['page'].data == '' + assert info['save_url'] == request.resource_url( + context, 'add_page', 'AnotherPage') + + def test_it_submitted(self): + context = testing.DummyResource() + request = testing.DummyRequest({ + 'form.submitted': True, + 'body': 'Hello yo!', + }) + request.subpath = ['AnotherPage'] + self._callFUT(context, request) + page = context['AnotherPage'] + assert page.data == 'Hello yo!' + assert page.__name__ == 'AnotherPage' + assert page.__parent__ == context + +class Test_edit_page: + def _callFUT(self, context, request): + from tutorial.views.default import edit_page + return edit_page(context, request) + + def test_it_notsubmitted(self): + context = testing.DummyResource() + request = testing.DummyRequest() + info = self._callFUT(context, request) + assert info['page'] == context + assert info['save_url'] == request.resource_url(context, 'edit_page') + + def test_it_submitted(self): + context = testing.DummyResource() + request = testing.DummyRequest({ + 'form.submitted': True, + 'body': 'Hello yo!', + }) + response = self._callFUT(context, request) + assert response.location == 'http://example.com/' + assert context.data == 'Hello yo!' diff --git a/docs/tutorials/wiki/src/tests/tutorial/__init__.py b/docs/tutorials/wiki/src/tests/tutorial/__init__.py index 935a5d6d2..2706cc184 100644 --- a/docs/tutorials/wiki/src/tests/tutorial/__init__.py +++ b/docs/tutorials/wiki/src/tests/tutorial/__init__.py @@ -1,11 +1,8 @@ from pyramid.config import Configurator from pyramid_zodbconn import get_connection -from pyramid.authentication import AuthTktAuthenticationPolicy -from pyramid.authorization import ACLAuthorizationPolicy - from .models import appmaker -from .security import groupfinder + def root_factory(request): conn = get_connection(request) @@ -15,17 +12,13 @@ def root_factory(request): def main(global_config, **settings): """ This function returns a Pyramid WSGI application. """ - authn_policy = AuthTktAuthenticationPolicy( - 'sosecret', callback=groupfinder, hashalg='sha512') - authz_policy = ACLAuthorizationPolicy() with Configurator(settings=settings) as config: - config.set_authentication_policy(authn_policy) - config.set_authorization_policy(authz_policy) + 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.include('.security') + config.set_root_factory(root_factory) config.scan() return config.make_wsgi_app() diff --git a/docs/tutorials/wiki/src/tests/tutorial/models/__init__.py b/docs/tutorials/wiki/src/tests/tutorial/models/__init__.py index ebd70e912..64ae4bf5c 100644 --- a/docs/tutorials/wiki/src/tests/tutorial/models/__init__.py +++ b/docs/tutorials/wiki/src/tests/tutorial/models/__init__.py @@ -4,13 +4,15 @@ from persistent.mapping import PersistentMapping from pyramid.security import ( Allow, Everyone, - ) +) class Wiki(PersistentMapping): __name__ = None __parent__ = None - __acl__ = [ (Allow, Everyone, 'view'), - (Allow, 'group:editors', 'edit') ] + __acl__ = [ + (Allow, Everyone, 'view'), + (Allow, 'group:editors', 'edit'), + ] class Page(Persistent): def __init__(self, data): diff --git a/docs/tutorials/wiki/src/tests/tutorial/security.py b/docs/tutorials/wiki/src/tests/tutorial/security.py index cbb3acd5d..9f51aa54c 100644 --- a/docs/tutorials/wiki/src/tests/tutorial/security.py +++ b/docs/tutorials/wiki/src/tests/tutorial/security.py @@ -1,4 +1,10 @@ import bcrypt +from pyramid.authentication import AuthTktCookieHelper +from pyramid.authorization import ACLHelper +from pyramid.security import ( + Authenticated, + Everyone, +) def hash_password(pw): @@ -11,10 +17,47 @@ def check_password(expected_hash, pw): return bcrypt.checkpw(pw.encode('utf-8'), expected_hash.encode('utf-8')) return False -USERS = {'editor': hash_password('editor'), - 'viewer': hash_password('viewer')} -GROUPS = {'editor':['group:editors']} +USERS = { + 'editor': hash_password('editor'), + 'viewer': hash_password('viewer'), +} +GROUPS = {'editor': ['group:editors']} -def groupfinder(userid, request): - if userid in USERS: - return GROUPS.get(userid, []) +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 not None and identity['userid'] in USERS: + return identity + + def authenticated_userid(self, request): + identity = self.authenticated_identity(request) + if identity is not None: + return identity['userid'] + + 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] + identity = self.authenticated_identity(request) + if identity is not None: + principals.append(Authenticated) + principals.append('u:' + identity['userid']) + principals.extend(GROUPS.get(identity['userid'], [])) + return principals + +def includeme(config): + settings = config.get_settings() + + config.set_security_policy(MySecurityPolicy(settings['auth.secret'])) diff --git a/docs/tutorials/wiki/src/tests/tutorial/templates/edit.pt b/docs/tutorials/wiki/src/tests/tutorial/templates/edit.pt index 6438b1569..488e7a6af 100644 --- a/docs/tutorials/wiki/src/tests/tutorial/templates/edit.pt +++ b/docs/tutorials/wiki/src/tests/tutorial/templates/edit.pt @@ -2,9 +2,6 @@ <div metal:fill-slot="content"> <div class="content"> - <p tal:condition="logged_in" class="pull-right"> - <a href="${request.application_url}/logout">Logout</a> - </p> <p> Editing <strong><span tal:replace="page.__name__"> Page Name Goes Here</span></strong> diff --git a/docs/tutorials/wiki/src/tests/tutorial/templates/layout.pt b/docs/tutorials/wiki/src/tests/tutorial/templates/layout.pt index 06a3c8157..61042da24 100644 --- a/docs/tutorials/wiki/src/tests/tutorial/templates/layout.pt +++ b/docs/tutorials/wiki/src/tests/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)</title> + <title><span tal:replace="page.__name__ | title"></span> - Pyramid tutorial wiki (based on TurboGears 20-Minute Wiki)</title> <!-- Bootstrap core CSS --> <link rel="stylesheet" href="//maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous"> @@ -33,6 +32,14 @@ <img class="logo img-responsive" src="${request.static_url('tutorial:static/pyramid.png')}" alt="pyramid web framework"> </div> <div class="col-md-10"> + <div class="content"> + <p tal:condition="request.authenticated_userid is None" class="pull-right"> + <a href="${request.application_url}/login">Login</a> + </p> + <p tal:condition="request.authenticated_userid is not None" class="pull-right"> + <a href="${request.application_url}/logout">Logout</a> + </p> + </div> <div metal:define-slot="content">No content</div> <div class="content"> <p>You can return to the @@ -42,6 +49,15 @@ </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 </div> diff --git a/docs/tutorials/wiki/src/tests/tutorial/templates/view.pt b/docs/tutorials/wiki/src/tests/tutorial/templates/view.pt index 911ab0c99..b8a6fbbaf 100644 --- a/docs/tutorials/wiki/src/tests/tutorial/templates/view.pt +++ b/docs/tutorials/wiki/src/tests/tutorial/templates/view.pt @@ -2,9 +2,6 @@ <div metal:fill-slot="content"> <div class="content"> - <p tal:condition="logged_in" class="pull-right"> - <a href="${request.application_url}/logout">Logout</a> - </p> <div tal:replace="structure page_text"> Page text goes here. </div> diff --git a/docs/tutorials/wiki/src/tests/tutorial/views/auth.py b/docs/tutorials/wiki/src/tests/tutorial/views/auth.py new file mode 100644 index 000000000..5062779a6 --- /dev/null +++ b/docs/tutorials/wiki/src/tests/tutorial/views/auth.py @@ -0,0 +1,51 @@ +from pyramid.httpexceptions import HTTPSeeOther +from pyramid.security import ( + forget, + remember, +) +from pyramid.view import ( + forbidden_view_config, + view_config, +) + +from ..security import check_password, USERS + + +@view_config(context='..models.Wiki', name='login', + renderer='tutorial:templates/login.pt') +@forbidden_view_config(renderer='tutorial:templates/login.pt') +def login(request): + login_url = request.resource_url(request.root, 'login') + referrer = request.url + if referrer == login_url: + referrer = '/' # never use the login form itself as came_from + came_from = request.params.get('came_from', referrer) + message = '' + login = '' + password = '' + if 'form.submitted' in request.params: + login = request.params['login'] + password = request.params['password'] + if check_password(USERS.get(login), password): + headers = remember(request, login) + return HTTPSeeOther(location=came_from, headers=headers) + message = 'Failed login' + request.response.status = 400 + + return dict( + message=message, + url=login_url, + came_from=came_from, + login=login, + password=password, + title='Login', + ) + + +@view_config(context='..models.Wiki', name='logout') +def logout(request): + headers = forget(request) + return HTTPSeeOther( + location=request.resource_url(request.context), + headers=headers, + ) diff --git a/docs/tutorials/wiki/src/tests/tutorial/views/default.py b/docs/tutorials/wiki/src/tests/tutorial/views/default.py index 7ba99c65b..5bb21fbcd 100644 --- a/docs/tutorials/wiki/src/tests/tutorial/views/default.py +++ b/docs/tutorials/wiki/src/tests/tutorial/views/default.py @@ -1,30 +1,21 @@ from docutils.core import publish_parts +from pyramid.httpexceptions import HTTPSeeOther +from pyramid.view import view_config import re -from pyramid.httpexceptions import HTTPFound -from pyramid.security import ( - forget, - remember, -) -from pyramid.view import ( - forbidden_view_config, - view_config, - ) - from ..models import Page -from ..security import check_password, USERS + # regular expression used to find WikiWords wikiwords = re.compile(r"\b([A-Z]\w+[A-Z]+\w+)") - -@view_config(context='..models.Wiki', - permission='view') +@view_config(context='..models.Wiki', permission='view') def view_wiki(context, request): - return HTTPFound(location=request.resource_url(context, 'FrontPage')) + return HTTPSeeOther(location=request.resource_url(context, 'FrontPage')) -@view_config(context='..models.Page', renderer='tutorial:templates/view.pt', +@view_config(context='..models.Page', + renderer='tutorial:templates/view.pt', permission='view') def view_page(context, request): wiki = context.__parent__ @@ -42,8 +33,7 @@ def view_page(context, request): page_text = publish_parts(context.data, writer_name='html')['html_body'] page_text = wikiwords.sub(check, page_text) edit_url = request.resource_url(context, 'edit_page') - return dict(page=context, page_text=page_text, edit_url=edit_url, - logged_in=request.authenticated_userid) + return dict(page=context, page_text=page_text, edit_url=edit_url) @view_config(name='add_page', context='..models.Wiki', @@ -57,13 +47,12 @@ def add_page(context, request): page.__name__ = pagename page.__parent__ = context context[pagename] = page - return HTTPFound(location=request.resource_url(page)) + return HTTPSeeOther(location=request.resource_url(page)) save_url = request.resource_url(context, 'add_page', pagename) page = Page('') page.__name__ = pagename page.__parent__ = context - return dict(page=page, save_url=save_url, - logged_in=request.authenticated_userid) + return dict(page=page, save_url=save_url) @view_config(name='edit_page', context='..models.Page', @@ -72,46 +61,9 @@ def add_page(context, request): def edit_page(context, request): if 'form.submitted' in request.params: context.data = request.params['body'] - return HTTPFound(location=request.resource_url(context)) - - return dict(page=context, - save_url=request.resource_url(context, 'edit_page'), - logged_in=request.authenticated_userid) - - -@view_config(context='..models.Wiki', name='login', - renderer='tutorial:templates/login.pt') -@forbidden_view_config(renderer='tutorial:templates/login.pt') -def login(request): - login_url = request.resource_url(request.context, 'login') - referrer = request.url - if referrer == login_url: - referrer = '/' # never use the login form itself as came_from - came_from = request.params.get('came_from', referrer) - message = '' - login = '' - password = '' - if 'form.submitted' in request.params: - login = request.params['login'] - password = request.params['password'] - if check_password(USERS.get(login), password): - headers = remember(request, login) - return HTTPFound(location=came_from, - headers=headers) - message = 'Failed login' + return HTTPSeeOther(location=request.resource_url(context)) return dict( - message=message, - url=request.application_url + '/login', - came_from=came_from, - login=login, - password=password, - title='Login', + page=context, + save_url=request.resource_url(context, 'edit_page'), ) - - -@view_config(context='..models.Wiki', name='logout') -def logout(request): - headers = forget(request) - return HTTPFound(location=request.resource_url(request.context), - headers=headers) diff --git a/docs/tutorials/wiki/src/views/.gitignore b/docs/tutorials/wiki/src/views/.gitignore index 1853d983c..e9336274d 100644 --- a/docs/tutorials/wiki/src/views/.gitignore +++ b/docs/tutorials/wiki/src/views/.gitignore @@ -11,7 +11,7 @@ dist/ nosetests.xml env*/ tmp/ -Data.fs* +Data*.fs* *.sublime-project *.sublime-workspace .*.sw? @@ -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..6a702ae12 --- /dev/null +++ b/docs/tutorials/wiki/src/views/tests/conftest.py @@ -0,0 +1,84 @@ +import os +from pyramid.paster import get_appsettings +from pyramid.scripting import prepare +from pyramid.testing import DummyRequest +import pytest +import transaction +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 tm(): + tm = transaction.manager + tm.begin() + tm.doom() + + yield tm + + tm.abort() + +@pytest.fixture +def testapp(app, tm): + testapp = webtest.TestApp(app, extra_environ={ + 'HTTP_HOST': 'example.com', + 'tm.active': True, + 'tm.manager': tm, + }) + + return testapp + +@pytest.fixture +def app_request(app, tm): + """ + 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' + request.tm = tm + + yield request + env['closer']() + +@pytest.fixture +def dummy_request(app, tm): + """ + 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.tm = tm + + 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/models/__init__.py b/docs/tutorials/wiki/src/views/tutorial/models/__init__.py index 7c6597afa..53e105a8e 100644 --- a/docs/tutorials/wiki/src/views/tutorial/models/__init__.py +++ b/docs/tutorials/wiki/src/views/tutorial/models/__init__.py @@ -1,6 +1,7 @@ from persistent import Persistent from persistent.mapping import PersistentMapping + class Wiki(PersistentMapping): __name__ = None __parent__ = None 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)</title> + <title><span tal:replace="page.__name__ | title"></span> - Pyramid tutorial wiki (based on TurboGears 20-Minute Wiki)</title> <!-- Bootstrap core CSS --> <link rel="stylesheet" href="//maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous"> @@ -42,6 +41,15 @@ </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 </div> diff --git a/docs/tutorials/wiki/src/views/tutorial/views/default.py b/docs/tutorials/wiki/src/views/tutorial/views/default.py index e7921cf2f..b3baa7e9a 100644 --- a/docs/tutorials/wiki/src/views/tutorial/views/default.py +++ b/docs/tutorials/wiki/src/views/tutorial/views/default.py @@ -1,18 +1,17 @@ from docutils.core import publish_parts -import re - -from pyramid.httpexceptions import HTTPFound +from pyramid.httpexceptions import HTTPSeeOther from pyramid.view import view_config +import re from ..models import Page + # regular expression used to find WikiWords wikiwords = re.compile(r"\b([A-Z]\w+[A-Z]+\w+)") - @view_config(context='..models.Wiki') def view_wiki(context, request): - return HTTPFound(location=request.resource_url(context, 'FrontPage')) + return HTTPSeeOther(location=request.resource_url(context, 'FrontPage')) @view_config(context='..models.Page', renderer='tutorial:templates/view.pt') @@ -45,7 +44,7 @@ def add_page(context, request): page.__name__ = pagename page.__parent__ = context context[pagename] = page - return HTTPFound(location=request.resource_url(page)) + return HTTPSeeOther(location=request.resource_url(page)) save_url = request.resource_url(context, 'add_page', pagename) page = Page('') page.__name__ = pagename @@ -58,7 +57,9 @@ def add_page(context, request): def edit_page(context, request): if 'form.submitted' in request.params: context.data = request.params['body'] - return HTTPFound(location=request.resource_url(context)) + return HTTPSeeOther(location=request.resource_url(context)) - return dict(page=context, - save_url=request.resource_url(context, 'edit_page')) + return dict( + page=context, + save_url=request.resource_url(context, 'edit_page'), + ) diff --git a/docs/tutorials/wiki/tests.rst b/docs/tutorials/wiki/tests.rst index f710b3b10..231945c9a 100644 --- a/docs/tutorials/wiki/tests.rst +++ b/docs/tutorials/wiki/tests.rst @@ -4,45 +4,99 @@ Adding Tests ============ -We will now add tests for the models and the views and a few functional tests in ``tests/test_it.py``. +We will now add tests for the models and the views and a few functional tests in the ``tests`` package. Tests ensure that an application works, and that it continues to work when changes are made in the future. -Test the models -=============== +Test harness +============ -We write tests for the ``model`` classes and the ``appmaker``. -We will modify our ``test_it.py`` file, writing a separate test class for each ``model`` class. -We will also write a test class for the ``appmaker``. +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. +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_``. -We will add three test classes, one for each of the following: +The harness consists of the following setup: -- the ``Page`` model named ``PageModelTests`` -- the ``Wiki`` model named ``WikiModelTests`` -- the appmaker named ``AppmakerTests`` +- ``pytest.ini`` - controls basic ``pytest`` configuration, including where to find the tests. + We have configured ``pytest`` to search for tests in the application package and in the ``tests`` package. +- ``.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. -Test the views -============== +- ``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. -We will modify our ``test_it.py`` file, adding tests for each view function that we added previously. -As a result, we will delete the ``ViewTests`` class that the ``zodb`` backend option provided, and add four other test classes: ``ViewWikiTests``, ``ViewPageTests``, ``AddPageTests``, and ``EditPageTests``. -These test the ``view_wiki``, ``view_page``, ``add_page``, and ``edit_page`` views. +- ``tests_require`` in ``setup.py`` - controls the dependencies installed when testing. + When the list is changed, it is necessary to re-run ``$VENV/bin/pip install -e ".[testing]"`` to ensure the new dependencies are installed. +- ``tests/conftest.py`` - the core fixtures available throughout our tests. + The fixtures are explained in more detail in the following sections. + Open ``tests/conftest.py`` and follow along. -Functional tests -================ -We will test the whole application, covering security aspects that are not tested in the unit tests, such as logging in, logging out, checking that the ``viewer`` user cannot add or edit pages, but the ``editor`` user can, and so on. -As a result we will add two test classes, ``SecurityTests`` and ``FunctionalTests``. +Session-scoped test fixtures +---------------------------- + +- ``app_settings`` - the settings ``dict`` parsed from the ``testing.ini`` file that would normally be passed by ``pserve`` into your app's ``main`` function. + +- ``app`` - the :app:`Pyramid` WSGI application, implementing the :class:`pyramid.interfaces.IRouter` interface. + Most commonly this would be used for functional tests. + + +Per-test fixtures +----------------- + +- ``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. + +- ``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 ``tm`` fixture is injected and used by any code that touches ``request.tm``. + This should join the ``request.root`` ZODB model to the transaction manager as well, to enable rolling back changes to the database. + The ``testapp`` maintains a cookiejar, so it can be used to share state across requests, as well as the transaction database connection. + +- ``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 will be fast and simple. + + +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 will test the password hashing features we added to ``tutorial.security`` and the rest of our models. + +Create ``tests/test_models.py`` such that it appears as follows: +.. literalinclude:: src/tests/tests/test_models.py + :linenos: + :language: python + + +Integration tests +================= + +We can directly execute the view code, bypassing :app:`Pyramid` and testing just the code that we have written. +These tests use dummy requests that we will prepare appropriately to set the conditions each view expects. + +Update ``tests/test_views.py`` such that it appears as follows: + +.. literalinclude:: src/tests/tests/test_views.py + :linenos: + :language: python + + +Functional tests +================ -View the results of all our edits to ``tests/test_it.py`` -========================================================= +We will 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 did not create, but that the ``editor`` user can, and so on. -Open the ``tests/test_it.py`` module, and edit it such that it appears as follows: +Update ``tests/test_functional.py`` such that it appears as follows: -.. literalinclude:: src/tests/tests/test_it.py +.. literalinclude:: src/tests/tests/test_functional.py :linenos: :language: python @@ -72,4 +126,4 @@ The expected result should look like the following: .. code-block:: text ......................... - 25 passed in 6.87 seconds + 25 passed in 3.87 seconds diff --git a/docs/tutorials/wiki2/authentication.rst b/docs/tutorials/wiki2/authentication.rst index 3f2fcec83..5519a967e 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,41 @@ 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. - - -Configure the app -~~~~~~~~~~~~~~~~~ - -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/authentication/tutorial/__init__.py - :linenos: - :emphasize-lines: 11 - :language: python +* 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 8-34). +* 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 42-44). + +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). + +Identifying the current user is done in a few steps: + +#. :app:`Pyramid` invokes a method on the policy requesting identity, userid, or permission to perform an operation. + +#. The policy starts by calling :meth:`pyramid.request.RequestLocalCache.get_or_create` to load the identity. + +#. 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. + +#. 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. + +#. 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 with long-running requests may want to avoid caching the identity, or tracking some extra metadata to re-verify it periodically against the authentication source. + + +Add new settings +~~~~~~~~~~~~~~~~ Our authentication policy is expecting a new setting, ``auth.secret``. Open the file ``development.ini`` and add the highlighted line below: @@ -97,7 +88,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 +97,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,17 +124,17 @@ 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 -Change the highlighted line. +Insert 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 +147,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 +212,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 +227,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 +261,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 +272,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 +301,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/authorization.rst b/docs/tutorials/wiki2/authorization.rst index 234f40e3b..001bde935 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,5-8,17,42-53 :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/basiclayout.rst b/docs/tutorials/wiki2/basiclayout.rst index ae58d80a5..ef78e052b 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. @@ -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/definingmodels.rst b/docs/tutorials/wiki2/definingmodels.rst index 4b80e09ac..129d77806 100644 --- a/docs/tutorials/wiki2/definingmodels.rst +++ b/docs/tutorials/wiki2/definingmodels.rst @@ -29,20 +29,23 @@ We need to add a dependency, the `bcrypt <https://pypi.org/project/bcrypt/>`_ pa package's ``setup.py`` file by assigning this dependency to the ``requires`` parameter in the ``setup()`` function. -Open ``tutorial/setup.py`` and edit it to look like the following: +Open ``tutorial/setup.py`` and edit it to look like the following by adding ``bcrypt`` and sorting the packages: .. literalinclude:: src/models/setup.py + :lines: 11-24 :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. Our cookiecutter does not have its packages sorted because it merely tacks on additional packages depending on our selections. -After adding ``bcrypt`` and sorting packages, we should have the above ``requires`` list. .. 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 +248,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/definingviews.rst b/docs/tutorials/wiki2/definingviews.rst index a434039ca..c4712faf0 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-25 + :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('<package>: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,40 @@ 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: + :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 +160,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 +186,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 +197,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 +211,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 +264,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 +275,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 +301,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 +323,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 +334,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 +361,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 +372,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 <https://palletsprojects.com/p/jinja/>`_ for more information about template - inheritance. +- Please refer to the `Jinja2 documentation <https://palletsprojects.com/p/jinja/>`_ for more information about template inheritance. The ``view.jinja2`` template @@ -360,8 +381,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 +405,8 @@ 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: + :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 +416,13 @@ containing a form and which provides the following: of the page (line 1). - Override the ``subtitle`` block to affect the ``<title>`` 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 +431,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: 5 + :language: python There are several important things to note about this configuration: diff --git a/docs/tutorials/wiki2/index.rst b/docs/tutorials/wiki2/index.rst index 40a194155..69e69684b 100644 --- a/docs/tutorials/wiki2/index.rst +++ b/docs/tutorials/wiki2/index.rst @@ -8,7 +8,7 @@ This tutorial introduces an :term:`SQLAlchemy` and :term:`URL dispatch`-based application with authentication and authorization. For cut and paste purposes, the source code for all stages of this -tutorial can be browsed on GitHub at `GitHub <https://github.com/Pylons/pyramid/>`_ for a specific branch or version under ``docs/tutorials/wiki2/src``, +tutorial can be browsed on `GitHub <https://github.com/Pylons/pyramid/>`_ for a specific branch or version under ``docs/tutorials/wiki2/src``, which corresponds to the same location if you have Pyramid sources. .. toctree:: diff --git a/docs/tutorials/wiki2/installation.rst b/docs/tutorials/wiki2/installation.rst index 55fca15a1..f016f19df 100644 --- a/docs/tutorials/wiki2/installation.rst +++ b/docs/tutorials/wiki2/installation.rst @@ -179,15 +179,15 @@ The console will show ``pip`` checking for packages and installing missing packa Successfully installed Jinja2-2.10.3 Mako-1.1.0 MarkupSafe-1.1.1 \ PasteDeploy-2.0.1 Pygments-2.5.2 SQLAlchemy-1.3.12 WebTest-2.0.33 \ - 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 \ + alembic-1.3.2 attrs-19.3.0 beautifulsoup4-4.8.2 coverage-5.0.3 \ + hupper-1.9.1 importlib-metadata-1.4.0 more-itertools-8.1.0 packaging-20.0 \ 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 \ - wcwidth-0.1.7 webob-1.8.5 zipp-0.6.0 zope.deprecation-4.4.0 \ + translationstring-1.3 tutorial venusian-3.0.0 waitress-1.4.2 \ + wcwidth-0.1.8 webob-1.8.5 zipp-0.6.0 zope.deprecation-4.4.0 \ zope.interface-4.7.1 zope.sqlalchemy-1.2 Testing requirements are defined in our project's ``setup.py`` file, in the ``tests_require`` and ``extras_require`` stanzas. @@ -315,8 +315,8 @@ For a successful test run, you should see output that ends like this: .. code-block:: bash - .. - 2 passed in 0.44 seconds + ..... + 5 passed in 0.44 seconds Expose test coverage information @@ -342,7 +342,7 @@ On Windows .. code-block:: doscon - c:\tutorial> %VENV%\Scripts\pytest --cov --cov-report=term-missing + %VENV%\Scripts\pytest --cov --cov-report=term-missing If successful, you will see output something like this: @@ -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: <somepath>/tutorial, inifile: pytest.ini, testpaths: tutorial + rootdir: <somepath>/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. @@ -383,32 +386,13 @@ Our package doesn't quite have 100% test coverage. Test and coverage cookiecutter defaults --------------------------------------- -Cookiecutters include configuration defaults for ``pytest`` and test coverage. -These configuration files are ``pytest.ini`` and ``.coveragerc``, located at -the root of your package. Without these defaults, we would need to specify the -path to the module on which we want to run tests and coverage. +The Pyramid cookiecutter includes configuration defaults for ``pytest`` and test coverage. +These configuration files are ``pytest.ini`` and ``.coveragerc``, located at the root of your package. -On Unix -^^^^^^^ - -.. code-block:: bash - - $VENV/bin/pytest --cov=tutorial tests -q - -On Windows -^^^^^^^^^^ - -.. code-block:: doscon - - %VENV%\Scripts\pytest --cov=tutorial tutorial\tests.py -q - -pytest follows :ref:`conventions for Python test discovery -<pytest:test discovery>`, and the configuration defaults from the cookiecutter -tell ``pytest`` where to find the module on which we want to run tests and -coverage. +``pytest`` follows :ref:`conventions for Python test discovery <pytest:test discovery>`. +The configuration defaults from the cookiecutter tell ``pytest`` where to find the module on which we want to run tests and coverage. -.. seealso:: See ``pytest``'s documentation for :ref:`pytest:usage` or invoke - ``pytest -h`` to see its full set of options. +.. seealso:: See ``pytest``'s documentation for :ref:`pytest:usage` or invoke ``pytest -h`` to see its full set of options. .. _wiki2-start-the-application: 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..d3c601f16 --- /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 = 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/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..1027ddd0a 100644 --- a/docs/tutorials/wiki2/src/authentication/tutorial/security.py +++ b/docs/tutorials/wiki2/src/authentication/tutorial/security.py @@ -1,27 +1,44 @@ -from pyramid.authentication import AuthTktAuthenticationPolicy -from pyramid.authorization import ACLAuthorizationPolicy +from pyramid.authentication import AuthTktCookieHelper +from pyramid.csrf import CookieCSRFStoragePolicy +from pyramid.request import RequestLocalCache -from .models import User +from . import models -class MyAuthenticationPolicy(AuthTktAuthenticationPolicy): +class MySecurityPolicy: + def __init__(self, secret): + self.authtkt = AuthTktCookieHelper(secret) + self.identity_cache = RequestLocalCache(self.load_identity) + + def load_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_identity(self, request): + return self.identity_cache.get_or_create(request) + 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,18 +35,29 @@ <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 </div> 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..378ce0ae9 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 html import escape 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) 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..d3c601f16 --- /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 = 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/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..7a99fb9e9 100644 --- a/docs/tutorials/wiki2/src/authorization/tutorial/security.py +++ b/docs/tutorials/wiki2/src/authorization/tutorial/security.py @@ -1,5 +1,7 @@ -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.request import RequestLocalCache from pyramid.security import ( Authenticated, Everyone, @@ -8,33 +10,54 @@ from pyramid.security import ( from . import models -class MyAuthenticationPolicy(AuthTktAuthenticationPolicy): +class MySecurityPolicy: + def __init__(self, secret): + self.authtkt = AuthTktCookieHelper(secret) + self.identity_cache = RequestLocalCache(self.load_identity) + self.acl = ACLHelper() + + def load_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_identity(self, request): + return self.identity_cache.get_or_create(request) + 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,18 +35,29 @@ <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 </div> 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/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..5caa1a8dc --- /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 = 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/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..5caa1a8dc --- /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 = 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/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..5caa1a8dc --- /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 = 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/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'} 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..1c8fb16d0 --- /dev/null +++ b/docs/tutorials/wiki2/src/tests/tests/conftest.py @@ -0,0 +1,166 @@ +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) + +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..007184af8 100644 --- a/docs/tutorials/wiki2/src/tests/tests/test_views.py +++ b/docs/tutorials/wiki2/src/tests/tests/test_views.py @@ -1,168 +1,109 @@ -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 _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 = 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 = self._makeContext(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 _makeContext(self, pagename): 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!') - - -class EditPageTests(BaseTest): + return NewPage(pagename) + + def test_get(self, dummy_request, dbsession): + dummy_request.user = makeUser('foo', 'editor') + 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): + dummy_request.method = 'POST' + dummy_request.POST['body'] = 'Hello yo!' + dummy_request.context = self._makeContext('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..7a99fb9e9 100644 --- a/docs/tutorials/wiki2/src/tests/tutorial/security.py +++ b/docs/tutorials/wiki2/src/tests/tutorial/security.py @@ -1,5 +1,7 @@ -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.request import RequestLocalCache from pyramid.security import ( Authenticated, Everyone, @@ -8,33 +10,54 @@ from pyramid.security import ( from . import models -class MyAuthenticationPolicy(AuthTktAuthenticationPolicy): +class MySecurityPolicy: + def __init__(self, secret): + self.authtkt = AuthTktCookieHelper(secret) + self.identity_cache = RequestLocalCache(self.load_identity) + self.acl = ACLHelper() + + def load_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_identity(self, request): + return self.identity_cache.get_or_create(request) + 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,18 +35,29 @@ <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 </div> 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/.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..5caa1a8dc --- /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 = 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/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 @@ -38,6 +38,15 @@ </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 </div> diff --git a/docs/tutorials/wiki2/src/views/tutorial/views/default.py b/docs/tutorials/wiki2/src/views/tutorial/views/default.py index 867ba3f6c..df0e4cb9e 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 html import escape 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) diff --git a/docs/tutorials/wiki2/tests.rst b/docs/tutorials/wiki2/tests.rst index c7d1a0f31..1bf38d988 100644 --- a/docs/tutorials/wiki2/tests.rst +++ b/docs/tutorials/wiki2/tests.rst @@ -8,108 +8,149 @@ 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,69-104,111,118-120 :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: .. code-block:: text - ............................... - 31 passed in 8.85 seconds + ........................... [100%] + 27 passed in 6.91s .. _webtest: https://docs.pylonsproject.org/projects/webtest/en/latest/ diff --git a/src/pyramid/authentication.py b/src/pyramid/authentication.py index 95c01bbea..3bfab78ee 100644 --- a/src/pyramid/authentication.py +++ b/src/pyramid/authentication.py @@ -430,9 +430,148 @@ class RemoteUserAuthenticationPolicy(CallbackAuthenticationPolicy): @implementer(IAuthenticationPolicy) class AuthTktAuthenticationPolicy(CallbackAuthenticationPolicy): """A :app:`Pyramid` :term:`authentication policy` which - obtains data from a Pyramid "auth ticket" cookie. See - :class:`.AuthTktCookieHelper` for documentation of the constructor - arguments. + obtains data from a Pyramid "auth ticket" cookie. + + Constructor Arguments + + ``secret`` + + The secret (a string) used for auth_tkt cookie signing. This value + should be unique across all values provided to Pyramid for various + subsystem secrets (see :ref:`admonishment_against_secret_sharing`). + Required. + + ``callback`` + + Default: ``None``. A callback passed the userid and the + request, expected to return ``None`` if the userid doesn't + exist or a sequence of principal identifiers (possibly empty) if + the user does exist. If ``callback`` is ``None``, the userid + will be assumed to exist with no principals. Optional. + + ``cookie_name`` + + Default: ``auth_tkt``. The cookie name used + (string). Optional. + + ``secure`` + + Default: ``False``. Only send the cookie back over a secure + conn. Optional. + + ``include_ip`` + + Default: ``False``. Make the requesting IP address part of + the authentication data in the cookie. Optional. + + For IPv6 this option is not recommended. The ``mod_auth_tkt`` + specification does not specify how to handle IPv6 addresses, so using + this option in combination with IPv6 addresses may cause an + incompatible cookie. It ties the authentication ticket to that + individual's IPv6 address. + + ``timeout`` + + Default: ``None``. Maximum number of seconds which a newly + issued ticket will be considered valid. After this amount of + time, the ticket will expire (effectively logging the user + out). If this value is ``None``, the ticket never expires. + Optional. + + ``reissue_time`` + + Default: ``None``. If this parameter is set, it represents the number + of seconds that must pass before an authentication token cookie is + automatically reissued as the result of a request which requires + authentication. The duration is measured as the number of seconds + since the last auth_tkt cookie was issued and 'now'. If this value is + ``0``, a new ticket cookie will be reissued on every request which + requires authentication. + + A good rule of thumb: if you want auto-expired cookies based on + inactivity: set the ``timeout`` value to 1200 (20 mins) and set the + ``reissue_time`` value to perhaps a tenth of the ``timeout`` value + (120 or 2 mins). It's nonsensical to set the ``timeout`` value lower + than the ``reissue_time`` value, as the ticket will never be reissued + if so. However, such a configuration is not explicitly prevented. + + Optional. + + ``max_age`` + + Default: ``None``. The max age of the auth_tkt cookie, in + seconds. This differs from ``timeout`` inasmuch as ``timeout`` + represents the lifetime of the ticket contained in the cookie, + while this value represents the lifetime of the cookie itself. + When this value is set, the cookie's ``Max-Age`` and + ``Expires`` settings will be set, allowing the auth_tkt cookie + to last between browser sessions. It is typically nonsensical + to set this to a value that is lower than ``timeout`` or + ``reissue_time``, although it is not explicitly prevented. + Optional. + + ``path`` + + Default: ``/``. The path for which the auth_tkt cookie is valid. + May be desirable if the application only serves part of a domain. + Optional. + + ``http_only`` + + Default: ``False``. Hide cookie from JavaScript by setting the + HttpOnly flag. Not honored by all browsers. + Optional. + + ``wild_domain`` + + Default: ``True``. An auth_tkt cookie will be generated for the + wildcard domain. If your site is hosted as ``example.com`` this + will make the cookie available for sites underneath ``example.com`` + such as ``www.example.com``. + Optional. + + ``parent_domain`` + + Default: ``False``. An auth_tkt cookie will be generated for the + parent domain of the current site. For example if your site is + hosted under ``www.example.com`` a cookie will be generated for + ``.example.com``. This can be useful if you have multiple sites + sharing the same domain. This option supercedes the ``wild_domain`` + option. + Optional. + + ``domain`` + + Default: ``None``. If provided the auth_tkt cookie will only be + set for this domain. This option is not compatible with ``wild_domain`` + and ``parent_domain``. + Optional. + + ``hashalg`` + + Default: ``sha512`` (the literal string). + + Any hash algorithm supported by Python's ``hashlib.new()`` function + can be used as the ``hashalg``. + + Cookies generated by different instances of AuthTktAuthenticationPolicy + using different ``hashalg`` options are not compatible. Switching the + ``hashalg`` will imply that all existing users with a valid cookie will + be required to re-login. + + Optional. + + ``debug`` + + Default: ``False``. If ``debug`` is ``True``, log messages to the + Pyramid debug logger about the results of various authentication + steps. The output from debugging is useful for reporting to maillist + or IRC channels when asking for support. + + ``samesite`` + + Default: ``'Lax'``. The 'samesite' option of the session cookie. Set + the value to ``None`` to turn off the samesite option. .. versionchanged:: 1.4 @@ -696,14 +835,6 @@ class AuthTktCookieHelper(object): subsystem secrets (see :ref:`admonishment_against_secret_sharing`). Required. - ``callback`` - - Default: ``None``. A callback passed the userid and the - request, expected to return ``None`` if the userid doesn't - exist or a sequence of principal identifiers (possibly empty) if - the user does exist. If ``callback`` is ``None``, the userid - will be assumed to exist with no principals. Optional. - ``cookie_name`` Default: ``auth_tkt``. The cookie name used @@ -821,12 +952,16 @@ class AuthTktCookieHelper(object): Default: ``False``. If ``debug`` is ``True``, log messages to the Pyramid debug logger about the results of various authentication steps. The output from debugging is useful for reporting to maillist - or IRC channels when asking for support. + or IRC channels when asking for support. Optional. ``samesite`` Default: ``'Lax'``. The 'samesite' option of the session cookie. Set - the value to ``None`` to turn off the samesite option. + the value to ``None`` to turn off the samesite option. Optional. + + .. versionchanged:: 2.0 + + The default ``hashalg`` was changed from ``md5`` to ``sha512``. """ @@ -860,7 +995,7 @@ class AuthTktCookieHelper(object): http_only=False, path="/", wild_domain=True, - hashalg='md5', + hashalg='sha512', parent_domain=False, domain=None, samesite='Lax', diff --git a/src/pyramid/config/testing.py b/src/pyramid/config/testing.py index db1aefb24..ea3f92d17 100644 --- a/src/pyramid/config/testing.py +++ b/src/pyramid/config/testing.py @@ -32,8 +32,8 @@ class TestingConfiguratorMixin(object): :attr:`pyramid.request.Request.authenticated_userid` will have this value as well. :type userid: str - :param identity: If provided, the policy's ``identify`` method will - return this value. As a result, + :param identity: If provided, the policy's ``authenticated_identity`` + method will return this value. As a result, :attr:`pyramid.request.Request.authenticated_identity`` will have this value. :type identity: object diff --git a/src/pyramid/interfaces.py b/src/pyramid/interfaces.py index 433ac0c9d..e92662f11 100644 --- a/src/pyramid/interfaces.py +++ b/src/pyramid/interfaces.py @@ -483,16 +483,16 @@ class IViewMapperFactory(Interface): class ISecurityPolicy(Interface): + def authenticated_identity(request): + """ Return the :term:`identity` of the current user. The object can be + of any shape, such as a simple ID string or an ORM object. + """ + def authenticated_userid(request): """ Return a :term:`userid` string identifying the trusted and verified user, or ``None`` if unauthenticated. """ - def identify(request): - """ Return the :term:`identity` of the current user. The object can be - of any shape, such as a simple ID string or an ORM object. - """ - def permits(request, context, permission): """ Return an instance of :class:`pyramid.security.Allowed` if a user of the given identity is allowed the ``permission`` in the current diff --git a/src/pyramid/security.py b/src/pyramid/security.py index a5b4ce442..657af045a 100644 --- a/src/pyramid/security.py +++ b/src/pyramid/security.py @@ -224,7 +224,7 @@ class SecurityAPIMixin: policy = _get_security_policy(self) if policy is None: return None - return policy.identify(self) + return policy.authenticated_identity(self) @property def authenticated_userid(self): @@ -357,7 +357,7 @@ class LegacySecurityPolicy: def _get_authz_policy(self, request): return request.registry.getUtility(IAuthorizationPolicy) - def identify(self, request): + def authenticated_identity(self, request): return self.authenticated_userid(request) def authenticated_userid(self, request): diff --git a/src/pyramid/testing.py b/src/pyramid/testing.py index a03f2678e..af02872dd 100644 --- a/src/pyramid/testing.py +++ b/src/pyramid/testing.py @@ -51,7 +51,7 @@ class DummySecurityPolicy(object): self.remember_result = remember_result self.forget_result = forget_result - def identify(self, request): + def authenticated_identity(self, request): return self.identity def authenticated_userid(self, request): diff --git a/tests/pkgs/securityapp/__init__.py b/tests/pkgs/securityapp/__init__.py index 6c9025e7d..facc37878 100644 --- a/tests/pkgs/securityapp/__init__.py +++ b/tests/pkgs/securityapp/__init__.py @@ -3,7 +3,7 @@ from pyramid.security import Allowed, Denied class SecurityPolicy: - def identify(self, request): + def authenticated_identity(self, request): raise NotImplementedError() # pragma: no cover def authenticated_userid(self, request): diff --git a/tests/test_security.py b/tests/test_security.py index fa3d165ea..db5861562 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -479,7 +479,7 @@ class TestLegacySecurityPolicy(unittest.TestCase): policy = LegacySecurityPolicy() _registerAuthenticationPolicy(request.registry, 'userid') - self.assertEqual(policy.identify(request), 'userid') + self.assertEqual(policy.authenticated_identity(request), 'userid') def test_remember(self): from pyramid.security import LegacySecurityPolicy @@ -532,7 +532,7 @@ class DummySecurityPolicy: def __init__(self, result): self.result = result - def identify(self, request): + def authenticated_identity(self, request): return self.result def authenticated_userid(self, request): diff --git a/tests/test_testing.py b/tests/test_testing.py index a5746b59d..dbda76454 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -27,9 +27,9 @@ class TestDummySecurityPolicy(unittest.TestCase): klass = self._getTargetClass() return klass(userid, identity, permissive) - def test_identify(self): + def test_authenticated_identity(self): policy = self._makeOne('user', 'identity') - self.assertEqual(policy.identify(None), 'identity') + self.assertEqual(policy.authenticated_identity(None), 'identity') def test_authenticated_userid(self): policy = self._makeOne('user') @@ -33,7 +33,7 @@ deps = [testenv:docs] whitelist_externals = make commands = - make -C docs doctest html epub BUILDDIR={envdir} "SPHINXOPTS=-W -E" + make -C docs {posargs:doctest html epub} BUILDDIR={envdir} "SPHINXOPTS=-W -E" extras = docs |
