From 3af8e8aaf20a194b335ad59a434dc4613e5a94f5 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 28 Dec 2025 22:48:36 -0600 Subject: [PATCH] fix: flush session when creating new object via MasterView whoops guess that got missed in the refactor. this also adds our first functional test! to reproduce the problem since unit tests didn't catch it. unfortunately i'm still missing something about how the functional TestApp is supposed to work, in conjunction with the test DB etc. seems to be acting strangely with regard to permission checks especially... --- pyproject.toml | 2 +- src/wuttaweb/testing.py | 49 +++++++++- src/wuttaweb/views/master.py | 3 + tasks.py | 3 +- tests/views/test_users.py | 181 ++++++++++++++++++++++++++++++++++- tox.ini | 12 ++- 6 files changed, 242 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 263fe66..5e85363 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,7 +53,7 @@ dependencies = [ [project.optional-dependencies] continuum = ["Wutta-Continuum>=0.3.0"] docs = ["Sphinx", "furo", "sphinxcontrib-programoutput"] -tests = ["pylint", "pytest", "pytest-cov", "tox"] +tests = ["pylint", "pytest", "pytest-cov", "tox", "WebTest"] [project.entry-points."fanstatic.libraries"] diff --git a/src/wuttaweb/testing.py b/src/wuttaweb/testing.py index 3a189a2..7ff4301 100644 --- a/src/wuttaweb/testing.py +++ b/src/wuttaweb/testing.py @@ -24,17 +24,19 @@ WuttaWeb - test utilities """ +import re import sys from unittest.mock import MagicMock import fanstatic import pytest from pyramid import testing +from webtest import TestApp from wuttjamaican.testing import DataTestCase from wuttjamaican.db.model.base import metadata -from wuttaweb import subscribers +from wuttaweb import subscribers, app as appmod from wuttaweb.conf import WuttaWebConfigExtension @@ -170,3 +172,48 @@ class VersionWebTestCase(WebTestCase): ext.startup(config) return config + + +# TODO: this interface likely needs to change. it is only used +# by a single test so far, in tests/views/test_users.py +@pytest.mark.functional +class FunctionalTestCase(DataTestCase): + """ + Base class for test suites requiring a fully complete web app, to + respond to functional tests. + """ + + wsgi_main_app = None + + def make_config(self, **kwargs): + sqlite_path = self.write_file("test.sqlite", "") + self.sqlite_engine_url = f"sqlite:///{sqlite_path}" + + config_path = self.write_file( + "test.ini", + f""" +[wutta.db] +default.url = {self.sqlite_engine_url} + +[alembic] +script_location = wuttjamaican.db:alembic +version_locations = wuttjamaican.db:alembic/versions +""", + ) + + return super().make_config([config_path], **kwargs) + + def make_webtest(self): + webapp = appmod.make_wsgi_app( + self.wsgi_main_app or appmod.main, config=self.config + ) + + return TestApp(webapp) + + def get_csrf_token(self, testapp): + res = testapp.get("/login") + match = re.search( + r'', res.text + ) + self.assertTrue(match) + return match.group(1) diff --git a/src/wuttaweb/views/master.py b/src/wuttaweb/views/master.py index 9f1c5d3..f1920ed 100644 --- a/src/wuttaweb/views/master.py +++ b/src/wuttaweb/views/master.py @@ -575,8 +575,11 @@ class MasterView(View): # pylint: disable=too-many-public-methods form = self.make_create_form() if form.validate(): + session = self.Session() try: result = self.save_create_form(form) + # nb. must always flush to ensure primary key is set + session.flush() except Exception as err: # pylint: disable=broad-exception-caught log.warning("failed to save 'create' form", exc_info=True) self.request.session.flash(f"Create failed: {err}", "error") diff --git a/tasks.py b/tasks.py index 1a51cac..7b9ae65 100644 --- a/tasks.py +++ b/tasks.py @@ -15,8 +15,9 @@ def release(c, skip_tests=False): Release a new version of WuttJamaican """ if not skip_tests: - c.run("pytest -m 'not versioned'") + c.run("pytest -m 'not versioned and not functional'") c.run("pytest -m 'versioned'") + c.run("pytest -m 'functional'") # rebuild pkg if os.path.exists("dist"): diff --git a/tests/views/test_users.py b/tests/views/test_users.py index e45ad26..81bb028 100644 --- a/tests/views/test_users.py +++ b/tests/views/test_users.py @@ -8,7 +8,7 @@ import colander from wuttaweb.grids import Grid from wuttaweb.views import users as mod -from wuttaweb.testing import WebTestCase +from wuttaweb.testing import WebTestCase, FunctionalTestCase class TestUserView(WebTestCase): @@ -471,3 +471,182 @@ class TestUserView(WebTestCase): ): result = view.delete_api_token() self.assertEqual(result, {"error": "API token not found"}) + + +# TODO: this test seems to work fine on its own, but not in conjunction +# with the next class below. will have to sort this out before adding +# anymore functional tests probably. but it can wait for the moment. +# class TestListUsers(FunctionalTestCase): + +# def setUp(self): +# super().setUp() +# model = self.app.model +# auth = self.app.get_auth_handler() + +# # add 'fred' user +# self.fred = model.User(username="fred") +# auth.set_user_password(self.fred, "fredpass") +# self.session.add(self.fred) + +# # add 'managers' role +# self.managers = model.Role(name="Managers") +# self.fred.roles.append(self.managers) +# self.session.add(self.managers) + +# self.session.commit() + +# def test_index(self): +# model = self.app.model +# auth = self.app.get_auth_handler() +# testapp = self.make_webtest() +# csrf = self.get_csrf_token(testapp) + +# # cannot list users if not logged in +# res = testapp.get("/users/") +# self.assertEqual(res.status_code, 200) +# self.assertIn("Access Denied", res.text) +# self.assertIn("Login", res.text) +# self.assertNotIn("fred", res.text) + +# # so we login +# res = testapp.post( +# "/login", +# params={ +# "_csrf": csrf, +# "username": "fred", +# "password": "fredpass", +# }, +# ) +# self.assertEqual(res.status_code, 302) +# self.assertEqual(res.location, "http://localhost/") +# res = res.follow() +# self.assertEqual(res.status_code, 200) +# self.assertNotIn("Login", res.text) +# self.assertIn("fred", res.text) + +# perms = self.session.query(model.Permission).all() +# self.assertEqual(len(perms), 0) +# self.assertFalse(auth.has_permission(self.session, self.fred, "users.list")) + +# # but we still cannot list users, b/c no perm +# res = testapp.get("/users/") +# self.assertEqual(res.status_code, 200) +# self.assertIn("Access Denied", res.text) +# self.assertNotIn("Login", res.text) +# self.assertIn("fred", res.text) + +# # so we grant the perm +# auth.grant_permission(self.managers, "users.list") +# self.session.commit() + +# perms = self.session.query(model.Permission).all() + +# # now we can list users +# res = testapp.get("/users/") +# self.assertEqual(res.status_code, 200) +# self.assertNotIn("Access Denied", res.text) +# self.assertNotIn("Login", res.text) +# self.assertIn("fred", res.text) + +# testapp.get("/logout") + + +class TestCreateUser(FunctionalTestCase): + + def setUp(self): + super().setUp() + model = self.app.model + auth = self.app.get_auth_handler() + + # add 'fred' user + self.fred = model.User(username="fred") + auth.set_user_password(self.fred, "fredpass") + self.session.add(self.fred) + + # add 'managers' role + self.managers = model.Role(name="Managers") + self.fred.roles.append(self.managers) + self.session.add(self.managers) + + self.session.commit() + + def test_create(self): + model = self.app.model + auth = self.app.get_auth_handler() + testapp = self.make_webtest() + csrf = self.get_csrf_token(testapp) + + # cannot create user if not logged in + res = testapp.get("/users/new") + self.assertEqual(res.status_code, 200) + self.assertIn("Access Denied", res.text) + self.assertIn("Login", res.text) + self.assertNotIn("fred", res.text) + + # so we login + res = testapp.post( + "/login", + params={ + "_csrf": csrf, + "username": "fred", + "password": "fredpass", + }, + ) + self.assertEqual(res.status_code, 302) + self.assertEqual(res.location, "http://localhost/") + res = res.follow() + self.assertEqual(res.status_code, 200) + self.assertNotIn("Login", res.text) + self.assertIn("fred", res.text) + + # but we still cannot create user, b/c no perm + res = testapp.get("/users/new") + self.assertEqual(res.status_code, 200) + self.assertIn("Access Denied", res.text) + self.assertNotIn("Login", res.text) + self.assertIn("fred", res.text) + + # so we grant the perm; then we can create user + auth.grant_permission(self.managers, "users.list") + auth.grant_permission(self.managers, "users.create") + auth.grant_permission(self.managers, "users.view") + self.session.commit() + + self.assertTrue(auth.has_permission(self.session, self.fred, "users.create")) + + # first get the form + res = testapp.get("/users/new") + self.assertEqual(res.status_code, 200) + self.assertNotIn("Access Denied", res.text) + self.assertNotIn("Login", res.text) + self.assertIn("fred", res.text) + self.assertIn("Username", res.text) + + # then post the form; user should be created + res = testapp.post( + "/users/new", + [ + ("_csrf", csrf), + ("username", "barney"), + ("__start__", "set_password:mapping"), + ("set_password", "barneypass"), + ("set_password-confirm", "barneypass"), + ("__end__", "set_password:mapping"), + ("first_name", "Barney"), + ("last_name", "Rubble"), + ("__start__", "roles:sequence"), + ("checkbox", str(self.managers.uuid)), + ("__end__", "roles:sequence"), + ], + ) + barney = self.session.query(model.User).filter_by(username="barney").first() + self.assertTrue(barney) + self.assertEqual(res.status_code, 302) + self.assertEqual(res.location, f"http://localhost/users/{barney.uuid}") + res = res.follow() + self.assertEqual(res.status_code, 200) + self.assertNotIn("Login", res.text) + self.assertIn("fred", res.text) + self.assertIn("Barney Rubble", res.text) + + testapp.get("/logout") diff --git a/tox.ini b/tox.ini index 5c91961..56adb42 100644 --- a/tox.ini +++ b/tox.ini @@ -5,12 +5,15 @@ envlist = py38, py39, py310, py311, nox [testenv] extras = continuum,tests commands = - pytest -m 'not versioned' {posargs} + pytest -m 'not versioned and not functional' {posargs} pytest -m 'versioned' {posargs} + pytest -m 'functional' {posargs} [testenv:nox] extras = tests -commands = pytest -m 'not versioned' {posargs} +commands = + pytest -m 'not versioned and not functional' {posargs} + pytest -m 'functional' {posargs} [testenv:pylint] basepython = python3.11 @@ -19,8 +22,9 @@ commands = pylint wuttaweb [testenv:coverage] basepython = python3.11 commands = - pytest -m 'not versioned' --cov=wuttaweb - pytest -m 'versioned' --cov-append --cov=wuttaweb --cov-report=html --cov-fail-under=100 + pytest -m 'not versioned and not functional' --cov=wuttaweb + pytest -m 'versioned' --cov-append --cov=wuttaweb --cov-report=html + pytest -m 'functional' --cov-append --cov=wuttaweb --cov-report=html --cov-fail-under=100 [testenv:docs] basepython = python3.11