diff options
Diffstat (limited to 'passlib')
-rw-r--r-- | passlib/ext/django/models.py | 2 | ||||
-rw-r--r-- | passlib/ext/django/utils.py | 140 | ||||
-rw-r--r-- | passlib/tests/test_drivers.py | 5 | ||||
-rw-r--r-- | passlib/tests/test_ext_django.py | 538 | ||||
-rw-r--r-- | passlib/tests/utils.py | 15 |
5 files changed, 659 insertions, 41 deletions
diff --git a/passlib/ext/django/models.py b/passlib/ext/django/models.py index aaf13ee..76117bd 100644 --- a/passlib/ext/django/models.py +++ b/passlib/ext/django/models.py @@ -29,6 +29,8 @@ def patch(): #parse & validate input value if not ctx: + # remove any patching that was already set, just in case. + set_django_password_context(None) return if ctx == "passlib-default": ctx = DEFAULT_CTX diff --git a/passlib/ext/django/utils.py b/passlib/ext/django/utils.py index 41d4196..200bfca 100644 --- a/passlib/ext/django/utils.py +++ b/passlib/ext/django/utils.py @@ -10,6 +10,7 @@ #imports #=================================================================== #site +from warnings import warn #pkg from passlib.utils import is_crypt_context, bytes #local @@ -19,61 +20,68 @@ __all__ = [ ] #=================================================================== -#lazy import +#lazy imports #=================================================================== -#NOTE: doing this lazily so sphinx can crawl module without -# having django import problems. -User = None #imported from django.contrib.auth.models +_dam = None #django.contrib.auth.models reference -def _lazy_import(): - global User - if User is None: - from django.contrib.auth.models import User +def _import_django(): + global _dam + if _dam is None: + import django.contrib.auth.models as _dam + return _dam #=================================================================== #constants #=================================================================== +#: base context mirroring django's setup +STOCK_CTX = """ +[passlib] +schemes = + django_salted_sha1, django_salted_md5, + django_des_crypt, hex_md5, + django_disabled + +default = django_salted_sha1 + +deprecated = hex_md5 +""" + #: default context used by app DEFAULT_CTX = """ [passlib] schemes = + sha512_crypt, pbkdf2_sha256, django_salted_sha1, django_salted_md5, django_des_crypt, hex_md5, django_disabled -default = pbkdf2_sha256 +default = sha512_crypt deprecated = + pbkdf2_sha256, django_salted_sha1, django_salted_md5, django_des_crypt, hex_md5 all__vary_rounds = 5%% -pbkdf2_sha256__default_rounds = 4000 -staff__pbkdf2_sha256__default_rounds = 8000 -superuser__pbkdf2_sha256__default_rounds = 10000 +sha512_crypt__default_rounds = 15000 +staff__sha512_crypt__default_rounds = 25000 +superuser__sha512_crypt__default_rounds = 35000 """ #=================================================================== -#monkeypatch framework +# helpers #=================================================================== -# NOTE: this moneypatcher was written to be useful -# outside of this module, and re-invokable, -# which is why it tries so hard to maintain -# sanity about it's patch state. - -_django_patch_state = None - def get_category(user): """default get_category() implementation used by set_django_password_context - + this is the function used if ``settings.PASSLIB_GET_CONTEXT`` is not specified. - + it maps superusers to the ``"superuser"`` category, staff to the ``"staff"`` category, and all others to the default category. @@ -88,12 +96,23 @@ def um(func): "unwrap method (eg User.set_password -> orig func)" return func.im_func +#=================================================================== +# monkeypatch framework +#=================================================================== + +# NOTE: this moneypatcher was written to be useful +# outside of this module, and re-invokable, +# which is why it tries so hard to maintain +# sanity about it's patch state. + +_django_patch_state = None #dict holding refs to undo patch + def set_django_password_context(context=None, get_category=get_category): """monkeypatches :mod:`!django.contrib.auth` to use specified password context. :arg context: Passlib context to use for Django password hashing. - If ``None``, restores original django functions. + If ``None``, restores original Django functions. In order to support existing hashes, any context specified should include @@ -109,28 +128,46 @@ def set_django_password_context(context=None, get_category=get_category): By default, uses a function which returns ``"superuser"`` for superusers, and ``"staff"`` for staff. + + This function monkeypatches the following parts of Django: + + * :func:`!django.contrib.auth.models.check_password` + * :meth:`!django.contrib.auth.models.User.check_password` + * :meth:`!django.contrib.auth.models.User.set_password` + + It also stores the provided context in + :data:`!django.contrib.auth.models.User.password_context`, + for easy access. """ - global _django_patch_state, User + global _django_patch_state, _dam + _import_django() state = _django_patch_state - _lazy_import() + User = _dam.User # issue warning if something else monkeypatched User # while our patch was applied. if state is not None: - if um(User.set_password) is not state['set_password']: - warning("another library has patched django's User.set_password") - if um(User.check_password) is not state['check_password']: - warning("another library has patched django's User.check_password") + if um(User.set_password) is not state['user_set_password']: + warn("another library has patched " + "django.contrib.auth.models:User.set_password") + if um(User.check_password) is not state['user_check_password']: + warn("another library has patched" + "django.contrib.auth.models:User.check_password") + if _dam.check_password is not state['models_check_password']: + warn("another library has patched" + "django.contrib.auth.models:check_password") #check if we should just restore original state if context is None: if state is not None: - User.pwd_context = None - User.set_password = state['orig_set_password'] - User.check_password = state['orig_check_password'] + del User.password_context + _dam.check_password = state['orig_models_check_password'] + User.set_password = state['orig_user_set_password'] + User.check_password = state['orig_user_check_password'] _django_patch_state = None return + #validate inputs if not is_crypt_context(context): raise TypeError("context must be CryptContext instance or None: %r" % (type(context),)) @@ -138,8 +175,9 @@ def set_django_password_context(context=None, get_category=get_category): #backup original state if this is first call if state is None: _django_patch_state = state = dict( - orig_check_password = um(User.check_password), - orig_set_password = um(User.set_password), + orig_user_check_password = um(User.check_password), + orig_user_set_password = um(User.set_password), + orig_models_check_password = _dam.check_password, ) #prepare replacements @@ -153,19 +191,45 @@ def set_django_password_context(context=None, get_category=get_category): def check_password(user, raw_password): "passlib replacement for User.check_password()" + if raw_password is None: + return False hash = user.password + if not hash or hash == _dam.UNUSABLE_PASSWORD: + return False cat = get_category(user) if get_category else None ok, new_hash = context.verify_and_update(raw_password, hash, category=cat) - if ok and new_hash: + if ok and new_hash is not None: user.password = new_hash user.save() return ok + def raw_check_password(raw_password, enc_password): + "passlib replacement for check_password()" + if not enc_password or enc_password == _dam.UNUSABLE_PASSWORD: + raise ValueError("no password hash specified") + return context.verify(raw_password, enc_password) + #set new state - User.pwd_context = context #just to make it easy to get to. - User.set_password = state['set_password'] = set_password - User.check_password = state['check_password'] = check_password + User.password_context = context + User.set_password = state['user_set_password'] = set_password + User.check_password = state['user_check_password'] = check_password + _dam.check_password = state['models_check_password'] = raw_check_password + state['context' ] = context + state['get_category'] = get_category + +##def get_django_password_context(): +## """return current django password context +## +## This returns the current :class:`~passlib.context.CryptContext` instance +## set by :func:`set_django_password_context`. +## If not context has been set, returns ``None``. +## """ +## global _django_patch_state +## if _django_patch_state: +## return _django_patch_state['context'] +## else: +## return None #=================================================================== #eof diff --git a/passlib/tests/test_drivers.py b/passlib/tests/test_drivers.py index 828f898..f39ef08 100644 --- a/passlib/tests/test_drivers.py +++ b/passlib/tests/test_drivers.py @@ -221,9 +221,10 @@ class _DjangoHelper(object): from django.conf import settings except ImportError: return self.skipTest("Django not installed") - settings.configure() + if not settings.configured: + settings.configure() from django.contrib.auth.models import check_password - for secret, hash in self.known_correct_hashes: + for secret, hash in self.all_correct_hashes: self.assertTrue(check_password(secret, hash)) self.assertFalse(check_password('x' + secret, hash)) diff --git a/passlib/tests/test_ext_django.py b/passlib/tests/test_ext_django.py new file mode 100644 index 0000000..8a32664 --- /dev/null +++ b/passlib/tests/test_ext_django.py @@ -0,0 +1,538 @@ +"""test passlib.ext.django""" +#========================================================= +#imports +#========================================================= +from __future__ import with_statement +#core +import logging; log = logging.getLogger(__name__) +import sys +import warnings +#site +#pkg +from passlib.context import CryptContext, CryptPolicy +from passlib.apps import django_context +from passlib.ext.django import utils +from passlib.hash import sha256_crypt +from passlib.tests.utils import TestCase, unittest, ut_version, catch_warnings +import passlib.tests.test_drivers as td +from passlib.utils import Undef +from passlib.registry import get_crypt_handler +#module + +#========================================================= +# import & configure django settings, +#========================================================= + +try: + from django.conf import settings, LazySettings + has_django = True +except ImportError: + settings = None + has_django = False + +if has_django: + if not isinstance(settings, LazySettings): + #this could mean django has been configured somehow, + #which we don't want, since test cases reset and manipulate settings. + raise RuntimeError("expected django.conf.settings to be LazySettings: %r" % (settings,)) + + #else configure a blank settings instance for our unittests + settings.configure() + +def update_settings(**kwds): + for k,v in kwds.iteritems(): + if v is Undef: + if hasattr(settings, k): + delattr(settings, k) + else: + setattr(settings, k, v) + +#========================================================= +# and prepare helper to skip all relevant tests +# if django isn't installed. +#========================================================= +def skipUnlessDjango(cls): + "helper to skip class if django not present" + if has_django: + return cls + if ut_version < 2: + return None + return unittest.skip("Django not installed")(cls) + +#========================================================= +# mock user object +#========================================================= +if has_django: + import django.contrib.auth.models as dam + + class FakeUser(dam.User): + "stub user object for testing" + #this mainly just overrides .save() to test commit behavior. + + saved_password = None + + def save(self): + self.saved_password = self.password + +#========================================================= +# helper contexts +#========================================================= + +# simple context which looks NOTHING like django, +# so we can tell if patching worked. +simple_context = CryptContext( + schemes = [ "md5_crypt", "des_crypt" ], + default = "md5_crypt", + deprecated = [ "des_crypt" ], +) + +# some sample hashes +sample1 = 'password' +sample1_md5 = '$1$kAd49ifN$biuRAv1Tv0zGHyCv0uIqW.' +sample1_des = 'PPPTDkiCeu/jM' +sample1_sha1 = 'sha1$b215d$9ee0a66f84ef1ad99096355e788135f7e949bd41' + +# context for testing category funcs +category_context = CryptContext( + schemes = [ "sha256_crypt" ], + sha256_crypt__rounds = 1000, + staff__sha256_crypt__rounds = 2000, + superuser__sha256_crypt__rounds = 3000, +) + +def get_cc_rounds(**kwds): + "helper for testing category funcs" + user = FakeUser(**kwds) + user.set_password("placeholder") + return sha256_crypt.from_string(user.password).rounds + +#========================================================= +# test utils +#========================================================= +class PatchTest(TestCase): + "test passlib.ext.django.utils:set_django_password_context" + + case_prefix = "passlib.ext.django utils" + + def assert_unpatched(self): + "helper to ensure django hasn't been patched" + state = utils._django_patch_state + + #make sure we aren't currently patched + self.assertIs(state, None) + + #make sure nothing else patches django + for func in [ + dam.check_password, + dam.User.check_password, + dam.User.set_password, + ]: + self.assertEquals(func.__module__, "django.contrib.auth.models") + self.assertFalse(hasattr(dam.User, "password_context")) + + def assert_patched(self, context=Undef): + "helper to ensure django HAS been patched" + state = utils._django_patch_state + + #make sure we're patched + self.assertIsNot(state, None) + + #make sure our methods are exposed + for func in [ + dam.check_password, + dam.User.check_password, + dam.User.set_password, + ]: + self.assertEquals(func.__module__, "passlib.ext.django.utils") + + #make sure methods match + self.assertIs(dam.check_password, state['models_check_password']) + self.assertIs(dam.User.check_password.im_func, state['user_check_password']) + self.assertIs(dam.User.set_password.im_func, state['user_set_password']) + + #make sure context matches + obj = dam.User.password_context + self.assertIs(obj, state['context']) + if context is not Undef: + self.assertIs(obj, context) + + #make sure old methods were stored + for key in [ + "orig_models_check_password", + "orig_user_check_password", + "orig_user_set_password", + ]: + value = state[key] + self.assertEquals(value.__module__, "django.contrib.auth.models") + + def setUp(self): + #reset to baseline, and verify + utils.set_django_password_context(None) + self.assert_unpatched() + + def tearDown(self): + #reset to baseline, and verify + utils.set_django_password_context(None) + self.assert_unpatched() + + def test_00_patch_control(self): + "test set_django_password_context patch/unpatch" + + #check context=None has no effect + utils.set_django_password_context(None) + self.assert_unpatched() + + #patch to use stock django context + utils.set_django_password_context(django_context) + self.assert_patched(context=django_context) + + #try to remove patch + utils.set_django_password_context(None) + self.assert_unpatched() + + def test_01_patch_control_detection(self): + "test set_django_password_context detection of foreign monkeypatches" + def dummy(): + pass + + with catch_warnings(record=True) as wlog: + warnings.simplefilter("always") + + #patch to use stock django context + utils.set_django_password_context(django_context) + self.assert_patched(context=django_context) + self.assertEquals(len(wlog), 0) + + #mess with User.set_password, make sure it's detected + dam.User.set_password = dummy + utils.set_django_password_context(django_context) + self.assert_patched(context=django_context) + self.assertEquals(len(wlog), 1) + self.assertWarningMatches(wlog.pop(), + message_re="^another library has patched.*User\.set_password$") + + #mess with user.check_password, make sure it's detected + dam.User.check_password = dummy + utils.set_django_password_context(django_context) + self.assert_patched(context=django_context) + self.assertEquals(len(wlog), 1) + self.assertWarningMatches(wlog.pop(), + message_re="^another library has patched.*User\.check_password$") + + #mess with user.check_password, make sure it's detected + dam.check_password = dummy + utils.set_django_password_context(django_context) + self.assert_patched(context=django_context) + self.assertEquals(len(wlog), 1) + self.assertWarningMatches(wlog.pop(), + message_re="^another library has patched.*models:check_password$") + + def test_01_patch_bad_types(self): + "test set_django_password_context bad inputs" + set = utils.set_django_password_context + self.assertRaises(TypeError, set, CryptPolicy()) + self.assertRaises(TypeError, set, "") + + def test_02_models_check_password(self): + "test monkeypatched models.check_password()" + + # patch to use simple context + utils.set_django_password_context(simple_context) + self.assert_patched(context=simple_context) + + # check correct hashes pass + self.assertTrue(dam.check_password(sample1, sample1_des)) + self.assertTrue(dam.check_password(sample1, sample1_md5)) + + # check bad password fail w/ false + self.assertFalse(dam.check_password('x', sample1_des)) + self.assertFalse(dam.check_password('x', sample1_md5)) + + # and other hashes fail w/ error + self.assertRaises(ValueError, dam.check_password, sample1, sample1_sha1) + self.assertRaises(ValueError, dam.check_password, sample1, None) + + def test_03_check_password(self): + "test monkeypatched User.check_password()" + # NOTE: using FakeUser so we can test .save() + user = FakeUser() + + # patch to use simple context + utils.set_django_password_context(simple_context) + self.assert_patched(context=simple_context) + + # test that blank hash is never accepted + self.assertIs(user.password, '') + self.assertIs(user.saved_password, None) + self.assertFalse(user.check_password('x')) + + # check correct secrets pass, and wrong ones fail + user.password = sample1_md5 + self.assertTrue(user.check_password(sample1)) + self.assertFalse(user.check_password('x')) + self.assertFalse(user.check_password(None)) + + # none of that should have triggered update of password + self.assertIs(user.password, sample1_md5) + self.assertIs(user.saved_password, None) + + #check unusable password + user.set_unusable_password() + self.assertFalse(user.has_usable_password()) + self.assertFalse(user.check_password(None)) + self.assertFalse(user.check_password('')) + self.assertFalse(user.check_password(sample1)) + + def test_04_check_password_migration(self): + "test User.check_password() hash migration" + # NOTE: using FakeUser so we can test .save() + user = FakeUser() + + # patch to use simple context + utils.set_django_password_context(simple_context) + self.assert_patched(context=simple_context) + + # set things up with a password that needs migration + user.password = sample1_des + self.assertIs(user.password, sample1_des) + self.assertIs(user.saved_password, None) + + # run check with bad password... + # shouldn't have migrated + self.assertFalse(user.check_password('x')) + self.assertFalse(user.check_password(None)) + + self.assertIs(user.password, sample1_des) + self.assertIs(user.saved_password, None) + + # run check with correct password... + # should have migrated to md5 and called save() + self.assertTrue(user.check_password(sample1)) + + self.assertTrue(user.password.startswith("$1$")) + self.assertIs(user.saved_password, user.password) + + # check resave doesn't happen + user.saved_password = None + self.assertTrue(user.check_password(sample1)) + self.assertIs(user.saved_password, None) + + def test_05_set_password(self): + "test monkeypatched User.set_password()" + user = FakeUser() + + # patch to use simple context + utils.set_django_password_context(simple_context) + self.assert_patched(context=simple_context) + + # sanity check + self.assertIs(user.password, '') + self.assertIs(user.saved_password, None) + self.assertTrue(user.has_usable_password()) + + # set password + user.set_password(sample1) + self.assertTrue(user.check_password(sample1)) + self.assertEquals(simple_context.identify(user.password), "md5_crypt") + self.assertIs(user.saved_password, None) + + #check unusable password + user.set_password(None) + self.assertFalse(user.has_usable_password()) + self.assertIs(user.saved_password, None) + + def test_06_get_category(self): + "test default get_category function" + func = utils.get_category + self.assertIs(func(FakeUser()), None) + self.assertEquals(func(FakeUser(is_staff=True)), "staff") + self.assertEquals(func(FakeUser(is_superuser=True)), "superuser") + self.assertEquals(func(FakeUser(is_staff=True, + is_superuser=True)), "superuser") + + def test_07_get_category(self): + "test set_django_password_context's get_category parameter" + # test patch uses default get_category + utils.set_django_password_context(category_context) + self.assertEquals(get_cc_rounds(), 1000) + self.assertEquals(get_cc_rounds(is_staff=True), 2000) + self.assertEquals(get_cc_rounds(is_superuser=True), 3000) + + # test patch uses explicit get_category + def get_category(user): + return user.first_name or None + utils.set_django_password_context(category_context, get_category) + self.assertEquals(get_cc_rounds(), 1000) + self.assertEquals(get_cc_rounds(first_name='other'), 1000) + self.assertEquals(get_cc_rounds(first_name='staff'), 2000) + self.assertEquals(get_cc_rounds(first_name='superuser'), 3000) + + # test patch can disable get_category + utils.set_django_password_context(category_context, None) + self.assertEquals(get_cc_rounds(), 1000) + self.assertEquals(get_cc_rounds(first_name='other'), 1000) + self.assertEquals(get_cc_rounds(first_name='staff', is_staff=True), 1000) + self.assertEquals(get_cc_rounds(first_name='superuser', is_superuser=True), 1000) + +PatchTest = skipUnlessDjango(PatchTest) + +#========================================================= +# test django plugin +#========================================================= + +django_hash_tests = [ + td.HexMd5Test, + td.DjangoDesCryptTest, + td.DjangoSaltedMd5Test, + td.DjangoSaltedSha1Test, + ] + +default_hash_tests = django_hash_tests + [ td.Builtin_SHA512CryptTest ] + +class PluginTest(TestCase): + "test django plugin via settings" + + case_prefix = "passlib.ext.django plugin" + + def setUp(self): + #remove django patch + utils.set_django_password_context(None) + + #ensure django settings are empty + update_settings( + PASSLIB_CONTEXT=Undef, + PASSLIB_GET_CATEGORY=Undef, + ) + + #unload module so it's re-run + sys.modules.pop("passlib.ext.django.models", None) + + def tearDown(self): + #remove django patch + utils.set_django_password_context(None) + + def check_hashes(self, tests, new_hash=None, deprecated=None): + u = FakeUser() + deprecated = None + + # check new hash construction + if new_hash: + u.set_password("placeholder") + handler = get_crypt_handler(new_hash) + self.assertTrue(handler.identify(u.password)) + + # run against hashes from tests... + for test in tests: + for secret, hash in test.all_correct_hashes: + + # check against valid password + u.password = hash + self.assertTrue(u.check_password(secret)) + if new_hash and deprecated and test.handler.name in deprecated: + self.assertFalse(handler.identify(hash)) + self.assertTrue(handler.identify(u.password)) + + # check against invalid password + u.password = hash + self.assertFalse(u.check_password('x'+secret)) + if new_hash and deprecated and test.handler.name in deprecated: + self.assertFalse(handler.identify(hash)) + self.assertEquals(u.password, hash) + + # check disabled handling + u.set_password(None) + handler = get_crypt_handler("django_disabled") + self.assertTrue(handler.identify(u.password)) + self.assertFalse(u.check_password('placeholder')) + + def test_00_actual_django(self): + "test actual Django behavior has not changed" + #NOTE: if this test fails, + # probably means newer version of Django, + # and passlib's policies should be updated. + self.check_hashes(django_hash_tests, + "django_salted_sha1", + ["hex_md5"]) + + def test_01_explicit_unset(self, value=None): + "test PASSLIB_CONTEXT = None" + update_settings( + PASSLIB_CONTEXT=value, + ) + import passlib.ext.django.models + self.check_hashes(django_hash_tests, + "django_salted_sha1", + ["hex_md5"]) + + def test_02_stock_ctx(self): + "test PASSLIB_CONTEXT = utils.STOCK_CTX" + self.test_01_explicit_unset(value=utils.STOCK_CTX) + + def test_03_implicit_default_ctx(self): + "test PASSLIB_CONTEXT unset" + import passlib.ext.django.models + self.check_hashes(default_hash_tests, + "sha512_crypt", + ["hex_md5", "django_salted_sha1", + "django_salted_md5", + "django_des_crypt", + ]) + + def test_04_explicit_default_ctx(self): + "test PASSLIB_CONTEXT = utils.DEFAULT_CTX" + update_settings( + PASSLIB_CONTEXT=utils.DEFAULT_CTX, + ) + self.test_03_implicit_default_ctx() + + def test_05_default_ctx_alias(self): + "test PASSLIB_CONTEXT = 'passlib-default'" + update_settings( + PASSLIB_CONTEXT="passlib-default", + ) + self.test_03_implicit_default_ctx() + + def test_06_categories(self): + "test PASSLIB_GET_CATEGORY unset" + update_settings( + PASSLIB_CONTEXT=category_context.policy, + ) + import passlib.ext.django.models + + self.assertEquals(get_cc_rounds(), 1000) + self.assertEquals(get_cc_rounds(is_staff=True), 2000) + self.assertEquals(get_cc_rounds(is_superuser=True), 3000) + + def test_07_categories_explicit(self): + "test PASSLIB_GET_CATEGORY = function" + def get_category(user): + return user.first_name or None + update_settings( + PASSLIB_CONTEXT = category_context.policy, + PASSLIB_GET_CATEGORY = get_category, + ) + import passlib.ext.django.models + + self.assertEquals(get_cc_rounds(), 1000) + self.assertEquals(get_cc_rounds(first_name='other'), 1000) + self.assertEquals(get_cc_rounds(first_name='staff'), 2000) + self.assertEquals(get_cc_rounds(first_name='superuser'), 3000) + + def test_08_categories_disabled(self): + "test PASSLIB_GET_CATEGORY = None" + update_settings( + PASSLIB_CONTEXT = category_context.policy, + PASSLIB_GET_CATEGORY = None, + ) + import passlib.ext.django.models + + self.assertEquals(get_cc_rounds(), 1000) + self.assertEquals(get_cc_rounds(first_name='other'), 1000) + self.assertEquals(get_cc_rounds(first_name='staff', is_staff=True), 1000) + self.assertEquals(get_cc_rounds(first_name='superuser', is_superuser=True), 1000) + +PluginTest = skipUnlessDjango(PluginTest) + +#========================================================= +#eof +#========================================================= diff --git a/passlib/tests/utils.py b/passlib/tests/utils.py index 7aa9797..a2e6a98 100644 --- a/passlib/tests/utils.py +++ b/passlib/tests/utils.py @@ -294,7 +294,7 @@ class TestCase(unittest.TestCase): raise self.failureException(msg) if not hasattr(unittest.TestCase, "assertRegexpMatches"): - #added in 2.7/UT2 and 3.1 + #added in 2.7/UT2 and 3.1 def assertRegexpMatches(self, text, expected_regex, msg=None): """Fail the test unless the text matches the regular expression.""" if isinstance(expected_regex, basestring): @@ -486,6 +486,19 @@ class HandlerCase(TestCase): name += " (%s backend)" % (get_backend(),) return name + @classproperty + def all_correct_hashes(cls): + hashes = cls.known_correct_hashes + configs = cls.known_correct_configs + if configs: + hashes = hashes + [ + (secret,hash) + for config,secret,hash + in configs + if (secret,hash) not in hashes + ] + return hashes + #========================================================= #setup / cleanup #========================================================= |