summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorEli Collins <elic@assurancetechnologies.com>2020-10-07 21:33:57 -0400
committerEli Collins <elic@assurancetechnologies.com>2020-10-07 21:33:57 -0400
commitcc21426e0f7a93e5dbd2c66d9752123924a1a4dc (patch)
tree38dbb2753e341cdbe1637e82c06ec5542037892d
parenta4f23dd8fed25cefc93cb257e00b16502a87dbd4 (diff)
downloadpasslib-cc21426e0f7a93e5dbd2c66d9752123924a1a4dc.tar.gz
passlib.ext.django: refactored tests to give more granular results.
* refactored DjangoBehaviorTest so instead of a monolothic "test_config()" method; there are now a bunch of different test methods. additionally, the ones with loops now use TestCase.subTest() to parameter things appropriately. *no changes to logic of the tests* * added TestCase.subTest() stub & wrapper to make debugging subtests easier. * added some documentation to test classes.
-rw-r--r--passlib/tests/test_ext_django.py562
-rw-r--r--passlib/tests/utils.py62
2 files changed, 424 insertions, 200 deletions
diff --git a/passlib/tests/test_ext_django.py b/passlib/tests/test_ext_django.py
index 8464fa0..2a0b418 100644
--- a/passlib/tests/test_ext_django.py
+++ b/passlib/tests/test_ext_django.py
@@ -24,7 +24,10 @@ from passlib.tests.test_handlers import get_handler_case
__all__ = [
"DjangoBehaviorTest",
"ExtensionBehaviorTest",
+ "DjangoExtensionTest",
+
"_ExtensionSupport",
+ "_ExtensionTest",
]
#=============================================================================
# configure django settings for testcases
@@ -200,11 +203,16 @@ sample_hashes = dict(
#=============================================================================
# test utils
#=============================================================================
+
class _ExtensionSupport(object):
- """support funcs for loading/unloading extension"""
+ """
+ test support funcs for loading/unloading extension.
+ this class is mixed in to various TestCase subclasses.
+ """
#===================================================================
# support funcs
#===================================================================
+
@classmethod
def _iter_patch_candidates(cls):
"""helper to scan for monkeypatches.
@@ -241,8 +249,11 @@ class _ExtensionSupport(object):
#===================================================================
# verify current patch state
#===================================================================
+
def assert_unpatched(self):
- """test that django is in unpatched state"""
+ """
+ test that django is in unpatched state
+ """
# make sure we aren't currently patched
mod = sys.modules.get("passlib.ext.django.models")
self.assertFalse(mod and mod.adapter.patched, "patch should not be enabled")
@@ -259,7 +270,9 @@ class _ExtensionSupport(object):
(obj, attr, source))
def assert_patched(self, context=None):
- """helper to ensure django HAS been patched, and is using specified config"""
+ """
+ helper to ensure django HAS been patched, and is using specified config
+ """
# make sure we're currently patched
mod = sys.modules.get("passlib.ext.django.models")
self.assertTrue(mod and mod.adapter.patched, "patch should have been enabled")
@@ -284,9 +297,13 @@ class _ExtensionSupport(object):
#===================================================================
# load / unload the extension (and verify it worked)
#===================================================================
+
_config_keys = ["PASSLIB_CONFIG", "PASSLIB_CONTEXT", "PASSLIB_GET_CATEGORY"]
+
def load_extension(self, check=True, **kwds):
- """helper to load extension with specified config & patch django"""
+ """
+ helper to load extension with specified config & patch django
+ """
self.unload_extension()
if check:
config = kwds.get("PASSLIB_CONFIG") or kwds.get("PASSLIB_CONTEXT")
@@ -298,7 +315,9 @@ class _ExtensionSupport(object):
self.assert_patched(context=config)
def unload_extension(self):
- """helper to remove patches and unload extension"""
+ """
+ helper to remove patches and unload extension
+ """
# remove patches and unload module
mod = sys.modules.get("passlib.ext.django.models")
if mod:
@@ -313,8 +332,19 @@ class _ExtensionSupport(object):
# eoc
#===================================================================
+
# XXX: rename to ExtensionFixture?
+# NOTE: would roll this into _ExtensionSupport class;
+# but we have to mix that one into django's TestCase classes as well;
+# and our TestCase class (and this setUp() method) would foul things up.
class _ExtensionTest(TestCase, _ExtensionSupport):
+ """
+ TestCase mixin which makes sure extension is unloaded before test;
+ and make sure it's unloaded after test as well.
+ """
+ #=============================================================================
+ # setup
+ #=============================================================================
def setUp(self):
super(_ExtensionTest, self).setUp()
@@ -332,13 +362,40 @@ class _ExtensionTest(TestCase, _ExtensionSupport):
# and do the same when the test exits
self.addCleanup(self.unload_extension)
+ #=============================================================================
+ # eoc
+ #=============================================================================
+
#=============================================================================
# extension tests
#=============================================================================
+
+#: static passwords used by DjangoBehaviorTest methods
+PASS1 = "toomanysecrets"
+WRONG1 = "letmein"
+
+
class DjangoBehaviorTest(_ExtensionTest):
- """tests model to verify it matches django's behavior"""
+ """
+ tests model to verify it matches django's behavior.
+
+ running this class verifies the tests correctly assert what Django itself does.
+
+ running the ExtensionBehaviorTest subclass below verifies "passlib.ext.django"
+ matches what the tests assert.
+ """
+ #=============================================================================
+ # class attrs
+ #=============================================================================
+
descriptionPrefix = "verify django behavior"
+
+ #: tracks whether tests should assume "passlib.ext.django" monkeypatch is applied.
+ #: (set to True by ExtensionBehaviorTest subclass)
patched = False
+
+ #: dict containing CryptContext() config which should match current django deploy.
+ #: used by tests to verify expected behavior.
config = stock_config
# NOTE: if this test fails, it means we're not accounting for
@@ -346,22 +403,30 @@ class DjangoBehaviorTest(_ExtensionTest):
# running against an untested version of django with a new
# hashing policy.
- @property
+ #=============================================================================
+ # test helpers
+ #=============================================================================
+
+ @memoized_property
def context(self):
+ """
+ per-test CryptContext() created from .config.
+ """
return CryptContext._norm_source(self.config)
def assert_unusable_password(self, user):
- """check that user object is set to 'unusable password' constant"""
+ """
+ check that user object is set to 'unusable password' constant
+ """
self.assertTrue(user.password.startswith("!"))
self.assertFalse(user.has_usable_password())
self.assertEqual(user.pop_saved_passwords(), [])
def assert_valid_password(self, user, hash=UNSET, saved=None):
- """check that user object has a usuable password hash.
-
+ """
+ check that user object has a usable password hash.
:param hash: optionally check it has this exact hash
- :param saved: check that mock commit history
- for user.password matches this list
+ :param saved: check that mock commit history for user.password matches this list
"""
if hash is UNSET:
self.assertNotEqual(user.password, "!")
@@ -373,55 +438,54 @@ class DjangoBehaviorTest(_ExtensionTest):
self.assertEqual(user.pop_saved_passwords(),
[] if saved is None else [saved])
- def test_config(self):
- """test hashing interface
+ #=============================================================================
+ # test hashing interface
+ #-----------------------------------------------------------------------------
+ # these functions are run against both the actual django code,
+ # to verify the assumptions of the unittests are correct;
+ # and run against the passlib extension, to verify it matches those assumptions.
+ #
+ # these tests check the following django methods:
+ # User.set_password()
+ # User.check_password()
+ # make_password() -- 1.4 only
+ # check_password()
+ # identify_hasher()
+ # User.has_usable_password()
+ # User.set_unusable_password()
+ #
+ # XXX: this take a while to run. what could be trimmed?
+ #
+ # TODO: add get_hasher() checks where appropriate in tests below.
+ #=============================================================================
- this function is run against both the actual django code, to
- verify the assumptions of the unittests are correct;
- and run against the passlib extension, to verify it matches
- those assumptions.
+ def test_extension_config(self):
"""
- log = self.getLogger()
- patched, config = self.patched, self.config
- # this tests the following methods:
- # User.set_password()
- # User.check_password()
- # make_password() -- 1.4 only
- # check_password()
- # identify_hasher()
- # User.has_usable_password()
- # User.set_unusable_password()
- # XXX: this take a while to run. what could be trimmed?
-
- # TODO: get_hasher()
-
- #=======================================================
- # setup helpers & imports
- #=======================================================
+ test extension config is loaded correctly
+ """
+ if not self.patched:
+ raise self.skipTest("extension not loaded")
+
ctx = self.context
- setter = create_mock_setter()
- PASS1 = "toomanysecrets"
- WRONG1 = "letmein"
-
- from django.contrib.auth.hashers import (check_password, make_password,
- is_password_usable, identify_hasher)
-
- #=======================================================
- # make sure extension is configured correctly
- #=======================================================
- if patched:
- # contexts should match
- from passlib.ext.django.models import password_context
- self.assertEqual(password_context.to_dict(resolve=True),
- ctx.to_dict(resolve=True))
-
- # should have patched both places
- from django.contrib.auth.models import check_password as check_password2
- self.assertEqual(check_password2, check_password)
-
- #=======================================================
- # default algorithm
- #=======================================================
+
+ # contexts should match
+ from django.contrib.auth.hashers import check_password
+ from passlib.ext.django.models import password_context
+ self.assertEqual(password_context.to_dict(resolve=True), ctx.to_dict(resolve=True))
+
+ # should have patched both places
+ from django.contrib.auth.models import check_password as check_password2
+ self.assertEqual(check_password2, check_password)
+
+ def test_default_algorithm(self):
+ """
+ test django's default algorithm
+ """
+ ctx = self.context
+
+ # NOTE: import has to be done w/in method, in case monkeypatching is applied by setUp()
+ from django.contrib.auth.hashers import make_password
+
# User.set_password() should use default alg
user = FakeUser()
user.set_password(PASS1)
@@ -436,9 +500,19 @@ class DjangoBehaviorTest(_ExtensionTest):
# check_password() - n/a
- #=======================================================
- # empty password behavior
- #=======================================================
+ def test_empty_password(self):
+ """
+ test how methods handle empty string as password
+ """
+ ctx = self.context
+
+ # NOTE: import has to be done w/in method, in case monkeypatching is applied by setUp()
+ from django.contrib.auth.hashers import (
+ check_password,
+ make_password,
+ is_password_usable,
+ identify_hasher,
+ )
# User.set_password() should use default alg
user = FakeUser()
@@ -451,14 +525,26 @@ class DjangoBehaviorTest(_ExtensionTest):
self.assertTrue(user.check_password(""))
self.assert_valid_password(user, hash)
- # no make_password()
+ # XXX: test make_password() ?
+
+ # TODO: is_password_usable()
+
+ # identify_hasher() -- na
# check_password() should return True
self.assertTrue(check_password("", hash))
- #=======================================================
- # 'unusable flag' behavior
- #=======================================================
+ def test_unusable_flag(self):
+ """
+ test how methods handle 'unusable flag' in hash
+ """
+ # NOTE: import has to be done w/in method, in case monkeypatching is applied by setUp()
+ from django.contrib.auth.hashers import (
+ check_password,
+ make_password,
+ is_password_usable,
+ identify_hasher,
+ )
# sanity check via user.set_unusable_password()
user = FakeUser()
@@ -488,9 +574,20 @@ class DjangoBehaviorTest(_ExtensionTest):
self.assertFalse(is_password_usable(user.password))
self.assertRaises(ValueError, identify_hasher, user.password)
- #=======================================================
- # hash=None
- #=======================================================
+ def test_none_hash_value(self):
+ """
+ test how methods handle None as hash value
+ """
+ patched = self.patched
+
+ # NOTE: import has to be done w/in method, in case monkeypatching is applied by setUp()
+ from django.contrib.auth.hashers import (
+ check_password,
+ make_password,
+ is_password_usable,
+ identify_hasher,
+ )
+
# User.set_password() - n/a
# User.check_password() - returns False
@@ -505,6 +602,8 @@ class DjangoBehaviorTest(_ExtensionTest):
self.assertEqual(user.has_usable_password(),
quirks.empty_is_usable_password)
+ # TODO: is_password_usable()
+
# make_password() - n/a
# check_password() - error
@@ -516,9 +615,17 @@ class DjangoBehaviorTest(_ExtensionTest):
# identify_hasher() - error
self.assertRaises(TypeError, identify_hasher, None)
- #=======================================================
- # empty hash values
- #=======================================================
+ def test_empty_hash_value(self):
+ """
+ test how methods handle empty string as hash value
+ """
+ # NOTE: import has to be done w/in method, in case monkeypatching is applied by setUp()
+ from django.contrib.auth.hashers import (
+ check_password,
+ make_password,
+ is_password_usable,
+ identify_hasher,
+ )
# User.set_password() - n/a
@@ -535,6 +642,8 @@ class DjangoBehaviorTest(_ExtensionTest):
# User.has_usable_password()
self.assertEqual(user.has_usable_password(), quirks.empty_is_usable_password)
+ # TODO: is_password_usable()
+
# make_password() - n/a
# check_password()
@@ -543,151 +652,192 @@ class DjangoBehaviorTest(_ExtensionTest):
# identify_hasher() - throws error
self.assertRaises(ValueError, identify_hasher, "")
- #=======================================================
- # invalid hash values
- #=======================================================
+ def test_invalid_hash_values(self):
+ """
+ test how methods handle invalid hash values.
+ """
for hash in [
"$789$foo", # empty identifier
]:
- # User.set_password() - n/a
+ with self.subTest(hash=hash):
+ self._do_test_invalid_hash_value(hash)
- # User.check_password()
- # As of django 1.5, invalid hash returns False (side effect of django issue 18453)
- user = FakeUser()
- user.password = hash
- self.assertFalse(user.check_password(PASS1))
+ def _do_test_invalid_hash_value(self, hash):
- # verify hash wasn't changed/upgraded during check_password() call
- self.assertEqual(user.password, hash)
- self.assertEqual(user.pop_saved_passwords(), [])
+ # NOTE: import has to be done w/in method, in case monkeypatching is applied by setUp()
+ from django.contrib.auth.hashers import (
+ check_password,
+ make_password,
+ is_password_usable,
+ identify_hasher,
+ )
+
+ # User.set_password() - n/a
- # User.has_usable_password()
- self.assertEqual(user.has_usable_password(), quirks.invalid_is_usable_password)
+ # User.check_password()
+ # As of django 1.5, invalid hash returns False (side effect of django issue 18453)
+ user = FakeUser()
+ user.password = hash
+ self.assertFalse(user.check_password(PASS1))
+
+ # verify hash wasn't changed/upgraded during check_password() call
+ self.assertEqual(user.password, hash)
+ self.assertEqual(user.pop_saved_passwords(), [])
- # make_password() - n/a
+ # User.has_usable_password()
+ self.assertEqual(user.has_usable_password(), quirks.invalid_is_usable_password)
- # check_password()
- self.assertFalse(check_password(PASS1, hash))
+ # TODO: is_password_usable()
- # identify_hasher() - throws error
- self.assertRaises(ValueError, identify_hasher, hash)
+ # make_password() - n/a
- #=======================================================
- # run through all the schemes in the context,
- # testing various bits of per-scheme behavior.
- #=======================================================
- for scheme in ctx.schemes():
+ # check_password()
+ self.assertFalse(check_password(PASS1, hash))
- #
- # TODO: break this loop up into separate parameterized tests.
- #
+ # identify_hasher() - throws error
+ self.assertRaises(ValueError, identify_hasher, hash)
- #-------------------------------------------------------
- # setup constants & imports, pick a sample secret/hash combo
- #-------------------------------------------------------
+ def test_available_schemes(self):
+ """
+ run a bunch of subtests for each hasher available in the default django setup
+ (as determined by reading self.context)
+ """
+ for scheme in self.context.schemes():
+ with self.subTest(scheme=scheme):
+ self._do_test_available_scheme(scheme)
- handler = ctx.handler(scheme)
- log.debug("testing scheme: %r => %r", scheme, handler)
- deprecated = ctx.handler(scheme).deprecated
- assert not deprecated or scheme != ctx.default_scheme()
- try:
- testcase = get_handler_case(scheme)
- except exc.MissingBackendError:
- continue
- assert handler_derived_from(handler, testcase.handler)
- if handler.is_disabled:
- continue
-
- # verify that django has a backend available
- # (since our hasher may use different set of backends,
- # get_handler_case() above may work, but django will have nothing)
- if not patched and not check_django_hasher_has_backend(handler.django_name):
- assert scheme in ["django_bcrypt", "django_bcrypt_sha256", "django_argon2"], \
- "%r scheme should always have active backend" % scheme
- # TODO: make this a SkipTest() once this loop has been parameterized.
- log.warn("skipping scheme %r due to missing django dependancy", scheme)
- continue
-
- # find a sample (secret, hash) pair to test with
- try:
- secret, hash = sample_hashes[scheme]
- except KeyError:
- get_sample_hash = testcase("setUp").get_sample_hash
- while True:
- secret, hash = get_sample_hash()
- if secret: # don't select blank passwords
- break
- other = 'dontletmein'
-
- #-------------------------------------------------------
- # User.set_password() - not tested here
- #-------------------------------------------------------
-
- #-------------------------------------------------------
- # User.check_password()+migration against known hash
- #-------------------------------------------------------
- user = FakeUser()
- user.password = hash
-
- # check against invalid password
- self.assertFalse(user.check_password(None))
- ##self.assertFalse(user.check_password(''))
- self.assertFalse(user.check_password(other))
+ def _do_test_available_scheme(self, scheme):
+ """
+ helper to test how specific hasher behaves.
+ :param scheme: *passlib* name of hasher (e.g. "django_pbkdf2_sha256")
+ """
+ log = self.getLogger()
+ ctx = self.context
+ patched = self.patched
+ setter = create_mock_setter()
+
+ # NOTE: import has to be done w/in method, in case monkeypatching is applied by setUp()
+ from django.contrib.auth.hashers import (
+ check_password,
+ make_password,
+ is_password_usable,
+ identify_hasher,
+ )
+
+ #-------------------------------------------------------
+ # setup constants & imports, pick a sample secret/hash combo
+ #-------------------------------------------------------
+ handler = ctx.handler(scheme)
+ log.debug("testing scheme: %r => %r", scheme, handler)
+ deprecated = ctx.handler(scheme).deprecated
+ assert not deprecated or scheme != ctx.default_scheme()
+ try:
+ testcase = get_handler_case(scheme)
+ except exc.MissingBackendError:
+ raise self.skipTest("backend not available")
+ assert handler_derived_from(handler, testcase.handler)
+ if handler.is_disabled:
+ raise self.skipTest("skip disabled hasher")
+
+ # verify that django has a backend available
+ # (since our hasher may use different set of backends,
+ # get_handler_case() above may work, but django will have nothing)
+ if not patched and not check_django_hasher_has_backend(handler.django_name):
+ assert scheme in ["django_bcrypt", "django_bcrypt_sha256", "django_argon2"], \
+ "%r scheme should always have active backend" % scheme
+ log.warning("skipping scheme %r due to missing django dependency", scheme)
+ raise self.skipTest("skip due to missing dependency")
+
+ # find a sample (secret, hash) pair to test with
+ try:
+ secret, hash = sample_hashes[scheme]
+ except KeyError:
+ get_sample_hash = testcase("setUp").get_sample_hash
+ while True:
+ secret, hash = get_sample_hash()
+ if secret: # don't select blank passwords
+ break
+ other = 'dontletmein'
+
+ #-------------------------------------------------------
+ # User.set_password() - not tested here
+ #-------------------------------------------------------
+
+ #-------------------------------------------------------
+ # User.check_password()+migration against known hash
+ #-------------------------------------------------------
+ user = FakeUser()
+ user.password = hash
+
+ # check against invalid password
+ self.assertFalse(user.check_password(None))
+ ##self.assertFalse(user.check_password(''))
+ self.assertFalse(user.check_password(other))
+ self.assert_valid_password(user, hash)
+
+ # check against valid password
+ self.assertTrue(user.check_password(secret))
+
+ # check if it upgraded the hash
+ # NOTE: needs_update kept separate in case we need to test rounds.
+ needs_update = deprecated
+ if needs_update:
+ self.assertNotEqual(user.password, hash)
+ self.assertFalse(handler.identify(user.password))
+ self.assertTrue(ctx.handler().verify(secret, user.password))
+ self.assert_valid_password(user, saved=user.password)
+ else:
self.assert_valid_password(user, hash)
- # check against valid password
- self.assertTrue(user.check_password(secret))
-
- # check if it upgraded the hash
- # NOTE: needs_update kept separate in case we need to test rounds.
- needs_update = deprecated
- if needs_update:
- self.assertNotEqual(user.password, hash)
- self.assertFalse(handler.identify(user.password))
- self.assertTrue(ctx.handler().verify(secret, user.password))
- self.assert_valid_password(user, saved=user.password)
- else:
- self.assert_valid_password(user, hash)
-
- # don't need to check rest for most deployments
- if TEST_MODE(max="default"):
- continue
-
- #-------------------------------------------------------
- # make_password() correctly selects algorithm
- #-------------------------------------------------------
- alg = DjangoTranslator().passlib_to_django_name(scheme)
- hash2 = make_password(secret, hasher=alg)
- self.assertTrue(handler.verify(secret, hash2))
-
- #-------------------------------------------------------
- # check_password()+setter against known hash
- #-------------------------------------------------------
- # should call setter only if it needs_update
- self.assertTrue(check_password(secret, hash, setter=setter))
- self.assertEqual(setter.popstate(), [secret] if needs_update else [])
-
- # should not call setter
- self.assertFalse(check_password(other, hash, setter=setter))
- self.assertEqual(setter.popstate(), [])
-
- ### check preferred kwd is ignored (feature we don't currently support fully)
- ##self.assertTrue(check_password(secret, hash, setter=setter, preferred='fooey'))
- ##self.assertEqual(setter.popstate(), [secret])
-
- # TODO: get_hasher()
-
- #-------------------------------------------------------
- # identify_hasher() recognizes known hash
- #-------------------------------------------------------
- self.assertTrue(is_password_usable(hash))
- name = DjangoTranslator().django_to_passlib_name(identify_hasher(hash).algorithm)
- self.assertEqual(name, scheme)
+ # don't need to check rest for most deployments
+ if TEST_MODE(max="default"):
+ return
+
+ #-------------------------------------------------------
+ # make_password() correctly selects algorithm
+ #-------------------------------------------------------
+ alg = DjangoTranslator().passlib_to_django_name(scheme)
+ hash2 = make_password(secret, hasher=alg)
+ self.assertTrue(handler.verify(secret, hash2))
+
+ #-------------------------------------------------------
+ # check_password()+setter against known hash
+ #-------------------------------------------------------
+ # should call setter only if it needs_update
+ self.assertTrue(check_password(secret, hash, setter=setter))
+ self.assertEqual(setter.popstate(), [secret] if needs_update else [])
+
+ # should not call setter
+ self.assertFalse(check_password(other, hash, setter=setter))
+ self.assertEqual(setter.popstate(), [])
+
+ ### check preferred kwd is ignored (feature we don't currently support fully)
+ ##self.assertTrue(check_password(secret, hash, setter=setter, preferred='fooey'))
+ ##self.assertEqual(setter.popstate(), [secret])
+
+ # TODO: get_hasher()
+
+ #-------------------------------------------------------
+ # identify_hasher() recognizes known hash
+ #-------------------------------------------------------
+ self.assertTrue(is_password_usable(hash))
+ name = DjangoTranslator().django_to_passlib_name(identify_hasher(hash).algorithm)
+ self.assertEqual(name, scheme)
+
+ #===================================================================
+ # eoc
+ #===================================================================
+
+#===================================================================
+# extension fidelity tests
+#===================================================================
class ExtensionBehaviorTest(DjangoBehaviorTest):
- """test model to verify passlib.ext.django conforms to it"""
+ """
+ test that "passlib.ext.django" conforms to behavioral assertions in DjangoBehaviorTest
+ """
descriptionPrefix = "verify extension behavior"
- patched = True
+
config = dict(
schemes="sha256_crypt,md5_crypt,des_crypt",
deprecated="des_crypt",
@@ -695,15 +845,29 @@ class ExtensionBehaviorTest(DjangoBehaviorTest):
def setUp(self):
super(ExtensionBehaviorTest, self).setUp()
+
+ # always load extension before each test
self.load_extension(PASSLIB_CONFIG=self.config)
+ self.patched = True
+
+#===================================================================
+# extension internal tests
+#===================================================================
class DjangoExtensionTest(_ExtensionTest):
- """test the ``passlib.ext.django`` plugin"""
+ """
+ test the ``passlib.ext.django`` plugin
+ """
+ #===================================================================
+ # class attrs
+ #===================================================================
+
descriptionPrefix = "passlib.ext.django plugin"
#===================================================================
# monkeypatch testing
#===================================================================
+
def test_00_patch_control(self):
"""test set_django_password_context patch/unpatch"""
diff --git a/passlib/tests/utils.py b/passlib/tests/utils.py
index 2534050..79a9f9f 100644
--- a/passlib/tests/utils.py
+++ b/passlib/tests/utils.py
@@ -29,7 +29,7 @@ from passlib.tests.backports import TestCase as _TestCase, skip, skipIf, skipUnl
from passlib.utils import has_rounds_info, has_salt_info, rounds_cost_values, \
rng as sys_rng, getrandstr, is_ascii_safe, to_native_str, \
repeat_string, tick, batch
-from passlib.utils.compat import iteritems, irange, u, unicode, PY2
+from passlib.utils.compat import iteritems, irange, u, unicode, PY2, nullcontext
from passlib.utils.decor import classproperty
import passlib.utils.handlers as uh
# local
@@ -628,6 +628,66 @@ class TestCase(_TestCase):
return value
#===================================================================
+ # subtests
+ #===================================================================
+
+ has_real_subtest = hasattr(_TestCase, "subTest")
+
+ @contextlib.contextmanager
+ def subTest(self, *args, **kwds):
+ """
+ wrapper/backport for .subTest() which also traps SkipTest errors.
+ (see source for details)
+ """
+ # this function works around two things:
+ # * TestCase.subTest() wasn't added until Py34; so for older python versions,
+ # we either need unittest2 installed, or provide stub of our own.
+ # this method provides a stub if needed (based on .has_real_subtest check)
+ #
+ # * as 2020-10-08, .subTest() doesn't play nicely w/ .skipTest();
+ # and also makes it hard to debug which subtest had a failure.
+ # (see https://bugs.python.org/issue25894 and https://bugs.python.org/issue35327)
+ # this method traps skipTest exceptions, and adds some logging to help debug
+ # which subtest caused the issue.
+
+ # setup way to log subtest info
+ # XXX: would like better way to inject messages into test output;
+ # but this at least gets us something for debugging...
+ # NOTE: this hack will miss parent params if called from nested .subTest()
+ def _render_title(_msg=None, **params):
+ out = ("[%s] " % _msg if _msg else "")
+ if params:
+ out += "(%s)" % " ".join("%s=%r" % tuple(item) for item in params.items())
+ return out.strip() or "<subtest>"
+
+ test_log = self.getLogger()
+ title = _render_title(*args, **kwds)
+
+ # use real subtest manager if available
+ if self.has_real_subtest:
+ ctx = super(TestCase, self).subTest(*args, **kwds)
+ else:
+ ctx = nullcontext()
+
+ # run the subtest
+ with ctx:
+ test_log.info("running subtest: %s", title)
+ try:
+ yield
+ except SkipTest:
+ # silence "SkipTest" exceptions, want to keep running next subtest.
+ test_log.info("subtest skipped: %s", title)
+ pass
+ except Exception as err:
+ # log unhandled exception occurred
+ # (assuming traceback will be reported up higher, so not bothering here)
+ test_log.warning("subtest failed: %s: %s: %r", title, type(err).__name__, str(err))
+ raise
+
+ # XXX: check for "failed" state in ``self._outcome`` before writing this?
+ test_log.info("subtest passed: %s", title)
+
+ #===================================================================
# other
#===================================================================
_mktemp_queue = None