diff options
| author | Michael Merickel <michael@merickel.org> | 2020-01-12 16:08:56 -0600 |
|---|---|---|
| committer | Michael Merickel <michael@merickel.org> | 2020-01-12 16:08:56 -0600 |
| commit | c77f619f79b32acb1e8866db43625f108daf2a18 (patch) | |
| tree | dba9d47c3dc6b70f749161876f28056f0503ce1a /docs | |
| parent | 3c484c3333672a7ed60436e14cd731458f7bd5e6 (diff) | |
| download | pyramid-c77f619f79b32acb1e8866db43625f108daf2a18.tar.gz pyramid-c77f619f79b32acb1e8866db43625f108daf2a18.tar.bz2 pyramid-c77f619f79b32acb1e8866db43625f108daf2a18.zip | |
upgrade the test harness
Diffstat (limited to 'docs')
19 files changed, 558 insertions, 348 deletions
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/tests.rst b/docs/tutorials/wiki/tests.rst index f710b3b10..e563b174e 100644 --- a/docs/tutorials/wiki/tests.rst +++ b/docs/tutorials/wiki/tests.rst @@ -4,45 +4,98 @@ 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`` config 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's 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 below. -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's touching ``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'll 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'll 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've written. +These tests use dummy requests that we'll 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'll test the whole application, covering security aspects that are not tested in the unit and integration tests, like logging in, logging out, checking that the ``basic`` user cannot edit pages that it didn't create but the ``editor`` user can, and so on. -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 +125,4 @@ The expected result should look like the following: .. code-block:: text ......................... - 25 passed in 6.87 seconds + 25 passed in 3.87 seconds |
