diff options
| author | Michael Merickel <michael@merickel.org> | 2020-01-16 10:01:29 -0600 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2020-01-16 10:01:29 -0600 |
| commit | 9c153e1250e00faa06003c10c3a26886489e6210 (patch) | |
| tree | 0bb4743f9e793e73c5f7a369d5744a1eff2e9c00 /docs/tutorials/wiki | |
| parent | 912bccb8b715b0249c2c23736c467eaee14a4e3b (diff) | |
| parent | cc26acfd29c94036d1c4d9164dba6a2b7792c00a (diff) | |
| download | pyramid-9c153e1250e00faa06003c10c3a26886489e6210.tar.gz pyramid-9c153e1250e00faa06003c10c3a26886489e6210.tar.bz2 pyramid-9c153e1250e00faa06003c10c3a26886489e6210.zip | |
Merge pull request #3557 from mmerickel/security-docs
update docs to use security policy
Diffstat (limited to 'docs/tutorials/wiki')
75 files changed, 1743 insertions, 809 deletions
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 |
