diff options
author | Eli Collins <elic@assurancetechnologies.com> | 2013-12-20 22:11:07 -0500 |
---|---|---|
committer | Eli Collins <elic@assurancetechnologies.com> | 2013-12-20 22:11:07 -0500 |
commit | 4af9748bcaed1085a8c1e64c2370fa00fb244f11 (patch) | |
tree | d9ab9cb8cd7aef752bccfb2c9461eef4530eb4ef | |
parent | 4c08f92f9caa64140e0010eae88179f260a28704 (diff) | |
download | passlib-4af9748bcaed1085a8c1e64c2370fa00fb244f11.tar.gz |
django compatibility updates (should fix issue 50)
passlib.ext.django & it's tests have gotten out of sync with django,
leading to a number of UT failures, as reported in issue 50.
tests now pass on django 1.2 through 1.6
passlib.ext.django
------------------
mimic changes in django's hasher logic:
* handle unsalted_sha1 hasher (django 1.4.6+)
* check_password(): empty hashes return False, rather throw error (django 1.5+
* allow empty passwords (django 1.6+)
* generate unusuable password suffixes (django 1.6+)
passlib.hash
------------
* django_des_crypt: added "use_duplicate_salt" class attr,
allowing tests to enable django 1.4+ style hashes which omit 1st salt.
* django_disabled: added support for django 1.6+ random suffixes
passlib.tests
-------------
* test_ext_django: lots of changes to verify django 1.5/1.6 behavior
* test_handlers_django: split django tests out of test_handlers
to make it easiers to run django-related tests.
* added workaround for encoding glitch in salted_md5 / salted_sha1 hashers (django 1.5+)
-rw-r--r-- | CHANGES | 13 | ||||
-rw-r--r-- | docs/lib/passlib.ext.django.rst | 6 | ||||
-rw-r--r-- | passlib/ext/django/models.py | 64 | ||||
-rw-r--r-- | passlib/ext/django/utils.py | 37 | ||||
-rw-r--r-- | passlib/handlers/django.py | 38 | ||||
-rw-r--r-- | passlib/tests/test_ext_django.py | 209 | ||||
-rw-r--r-- | passlib/tests/test_handlers.py | 268 | ||||
-rw-r--r-- | passlib/tests/test_handlers_django.py | 297 | ||||
-rw-r--r-- | passlib/tests/utils.py | 16 | ||||
-rw-r--r-- | tox.ini | 87 |
10 files changed, 697 insertions, 338 deletions
@@ -4,6 +4,19 @@ Release History =============== +**1.6.2** (NOT YET RELEASED) +============================ + + Bugfix release + + * *Django compatibility* -- Passlib's Django extension (:mod:`passlib.ext.django`), + and it's related hashes and unittests, have been updated to handle + some minor API changes in Django 1.5-1.6. They should now be compatible with Django 1.2 and up. + + .. note:: + + As of Passlib 1.7, :mod:`passlib.ext.django` will require Django >= 1.4. + **1.6.1** (2012-08-02) ====================== diff --git a/docs/lib/passlib.ext.django.rst b/docs/lib/passlib.ext.django.rst index 7905d3f..f9ac7a2 100644 --- a/docs/lib/passlib.ext.django.rst +++ b/docs/lib/passlib.ext.django.rst @@ -8,6 +8,10 @@ .. versionadded:: 1.6 +.. note:: + + As of Passlib 1.7, :mod:`passlib.ext.django` will require Django >= 1.4. + This module contains a `Django <http://www.djangoproject.com>`_ plugin which overriddes all of Django's password hashing functions, replacing them with wrappers around a Passlib :ref:`CryptContext <context-overview>` object @@ -31,7 +35,7 @@ of uses: * Mark any hash algorithms as deprecated, and automatically migrate to stronger hashes when the user logs in. -.. warning:: +.. note:: This plugin should be considered "release candidate" quality. It works, and has good unittest coverage, but has seen only diff --git a/passlib/ext/django/models.py b/passlib/ext/django/models.py index b4a40da..6c4d245 100644 --- a/passlib/ext/django/models.py +++ b/passlib/ext/django/models.py @@ -72,20 +72,39 @@ def _apply_patch(): from django.contrib.auth.models import UNUSABLE_PASSWORD def is_password_usable(encoded): - return (encoded is not None and encoded != UNUSABLE_PASSWORD) + return encoded is not None and encoded != UNUSABLE_PASSWORD def is_valid_secret(secret): return secret is not None - else: + elif VERSION < (1,6): has_hashers = True from django.contrib.auth.hashers import UNUSABLE_PASSWORD, \ is_password_usable + # NOTE: 1.4 - 1.5 - empty passwords no longer valid. def is_valid_secret(secret): - # NOTE: changed in 1.4 - empty passwords no longer valid. return bool(secret) + else: + has_hashers = True + from django.contrib.auth.hashers import is_password_usable + + # 1.6 - empty passwords valid again + def is_valid_secret(secret): + return secret is not None + + if VERSION < (1,6): + def make_unusable_password(): + return UNUSABLE_PASSWORD + else: + from django.contrib.auth.hashers import make_password as _make_password + def make_unusable_password(): + return _make_password(None) + + # django 1.4.6+ uses a separate hasher for "sha1$$digest" hashes + has_unsalted_sha1 = (VERSION >= (1,4,6)) + # # backport ``User.set_unusable_password()`` for Django 0.9 # (simplifies rest of the code) @@ -95,7 +114,7 @@ def _apply_patch(): @_manager.monkeypatch(USER_PATH) def set_unusable_password(user): - user.password = UNUSABLE_PASSWORD + user.password = make_unusable_password() @_manager.monkeypatch(USER_PATH) def has_usable_password(user): @@ -123,6 +142,8 @@ def _apply_patch(): hash = user.password if not is_valid_secret(password) or not is_password_usable(hash): return False + if not hash and VERSION < (1,4): + return False # NOTE: pulls _get_category from module globals cat = _get_category(user) ok, new_hash = password_context.verify_and_update(password, hash, @@ -159,12 +180,18 @@ def _apply_patch(): def make_password(password, salt=None, hasher="default"): "passlib replacement for make_password()" if not is_valid_secret(password): - return UNUSABLE_PASSWORD - kwds = {} - if salt is not None: + return make_unusable_password() + if hasher == "default": + scheme = None + else: + scheme = hasher_to_passlib_name(hasher) + kwds = dict(scheme=scheme) + handler = password_context.handler(scheme) + # NOTE: django make specify an empty string for the salt, + # even if scheme doesn't accept a salt. we omit keyword + # in that case. + if salt is not None and (salt or 'salt' in handler.setting_kwds): kwds['salt'] = salt - if hasher != "default": - kwds['scheme'] = hasher_to_passlib_name(hasher) return password_context.encrypt(password, **kwds) @_manager.monkeypatch(HASHERS_PATH) @@ -175,18 +202,29 @@ def _apply_patch(): scheme = None else: scheme = hasher_to_passlib_name(algorithm) + # NOTE: resolving scheme -> handler instead of + # passing scheme into get_passlib_hasher(), + # in case context contains custom handler + # shadowing name of a builtin handler. handler = password_context.handler(scheme) - return get_passlib_hasher(handler) + return get_passlib_hasher(handler, algorithm=algorithm) - # NOTE: custom helper that doesn't exist in django proper - # (though submitted a patch - https://code.djangoproject.com/ticket/18184) + # identify_hasher() was added in django 1.5, + # patching it anyways for 1.4, so passlib's version is always available. @_manager.monkeypatch(HASHERS_PATH) @_manager.monkeypatch(FORMS_PATH) def identify_hasher(encoded): "passlib helper to identify hasher from encoded password" handler = password_context.identify(encoded, resolve=True, required=True) - return get_passlib_hasher(handler) + algorithm = None + if (has_unsalted_sha1 and handler.name == "django_salted_sha1" and + encoded.startswith("sha1$$")): + # django 1.4.6+ uses a separate hasher for "sha1$$digest" hashes, + # but passlib just reuses the "sha1$salt$digest" handler. + # we want to resolve to correct django hasher. + algorithm = "unsalted_sha1" + return get_passlib_hasher(handler, algorithm=algorithm) _patched = True log.debug("... finished monkeypatching django") diff --git a/passlib/ext/django/utils.py b/passlib/ext/django/utils.py index 3c03637..ab10b6f 100644 --- a/passlib/ext/django/utils.py +++ b/passlib/ext/django/utils.py @@ -119,6 +119,10 @@ def hasher_to_passlib_name(hasher_name): "convert hasher name -> passlib handler name" if hasher_name.startswith(PASSLIB_HASHER_PREFIX): return hasher_name[len(PASSLIB_HASHER_PREFIX):] + if hasher_name == "unsalted_sha1": + # django 1.4.6+ uses a separate hasher for "sha1$$digest" hashes, + # but passlib just reuses the "sha1$salt$digest" handler. + hasher_name = "sha1" for name in list_crypt_handlers(): if name.startswith(DJANGO_PASSLIB_PREFIX) or name in _other_django_hashes: handler = get_crypt_handler(name) @@ -132,13 +136,14 @@ def hasher_to_passlib_name(hasher_name): #============================================================================= # wrapping passlib handlers as django hashers #============================================================================= -_FAKE_SALT = "--fake-salt--" +_GEN_SALT_SIGNAL = "--!!!generate-new-salt!!!--" class _HasherWrapper(object): """helper for wrapping passlib handlers in Hasher-compatible class.""" # filled in by subclass, drives the other methods. passlib_handler = None + iterations = None @classproperty def algorithm(cls): @@ -146,19 +151,22 @@ class _HasherWrapper(object): return PASSLIB_HASHER_PREFIX + cls.passlib_handler.name def salt(self): - # XXX: our encode wrapper generates a new salt each time it's called, - # so just returning a 'no value' flag here. - return _FAKE_SALT + # NOTE: passlib's handler.encrypt() should generate new salt each time, + # so this just returns a special constant which tells + # encode() (below) not to pass a salt keyword along. + return _GEN_SALT_SIGNAL def verify(self, password, encoded): return self.passlib_handler.verify(password, encoded) def encode(self, password, salt=None, iterations=None): kwds = {} - if salt is not None and salt != _FAKE_SALT: + if salt is not None and salt != _GEN_SALT_SIGNAL: kwds['salt'] = salt if iterations is not None: kwds['rounds'] = iterations + elif self.iterations is not None: + kwds['rounds'] = self.iterations return self.passlib_handler.encrypt(password, **kwds) _translate_kwds = dict(checksum="hash", rounds="iterations") @@ -178,10 +186,17 @@ class _HasherWrapper(object): items.append((_(key), value)) return SortedDict(items) + # added in django 1.6 + def must_update(self, encoded): + # TODO: would like to do something useful here, + # but would require access to password context, + # which would mean a serious recoding of this ext. + return False + # cache of hasher wrappers generated by get_passlib_hasher() _hasher_cache = WeakKeyDictionary() -def get_passlib_hasher(handler): +def get_passlib_hasher(handler, algorithm=None): """create *Hasher*-compatible wrapper for specified passlib hash. This takes in the name of a passlib hash (or the handler object itself), @@ -204,8 +219,14 @@ def get_passlib_hasher(handler): handler = get_crypt_handler(handler) if hasattr(handler, "django_name"): # return native hasher instance - # XXX: should cache this too. - return _get_hasher(handler.django_name) + # XXX: should add this to _hasher_cache[] + name = handler.django_name + if name == "sha1" and algorithm == "unsalted_sha1": + # django 1.4.6+ uses a separate hasher for "sha1$$digest" hashes, + # but passlib just reuses the "sha1$salt$digest" handler. + # we want to resolve to correct django hasher. + name = algorithm + return _get_hasher(name) if handler.name == "django_disabled": raise ValueError("can't wrap unusable-password handler") try: diff --git a/passlib/handlers/django.py b/passlib/handlers/django.py index 5e67abf..b643a66 100644 --- a/passlib/handlers/django.py +++ b/passlib/handlers/django.py @@ -224,7 +224,7 @@ class django_pbkdf2_sha256(DjangoVariableHash): max_rounds = 0xffffffff # setting at 32-bit limit for now checksum_chars = uh.PADDED_BASE64_CHARS checksum_size = 44 # 32 bytes -> base64 - default_rounds = 10000 # NOTE: using django default here + default_rounds = 12000 # NOTE: using django default here _prf = "hmac-sha256" def _calc_checksum(self, secret): @@ -311,6 +311,21 @@ class django_des_crypt(uh.HasSalt, uh.GenericHandler): min_salt_size = default_salt_size = 2 _stub_checksum = u('.')*11 + # NOTE: regarding duplicate salt field: + # + # django 1.0 had a "crypt$<salt1>$<salt2><digest>" hash format, + # used [a-z0-9] to generate a 5 char salt, stored it in salt1, + # duplicated the first two chars of salt1 as salt2. + # it would throw an error if salt1 was empty. + # + # django 1.4 started generating 2 char salt using the full alphabet, + # left salt1 empty, and only paid attention to salt2. + # + # in order to be compatible with django 1.0, the hashes generated + # by this function will always include salt1, unless the following + # class-level field is disabled (mainly used for testing) + use_duplicate_salt = True + @classmethod def from_string(cls, hash): salt, chk = uh.parse_mc2(hash, cls.ident, handler=cls) @@ -331,11 +346,14 @@ class django_des_crypt(uh.HasSalt, uh.GenericHandler): return cls(salt=salt, checksum=chk) def to_string(self): - # NOTE: always filling in salt field, so that we're compatible - # with django 1.0 (which requires it) salt = self.salt chk = salt[:2] + (self.checksum or self._stub_checksum) - return uh.render_mc2(self.ident, salt, chk) + if self.use_duplicate_salt: + # filling in salt field, so that we're compatible with django 1.0 + return uh.render_mc2(self.ident, salt, chk) + else: + # django 1.4+ style hash + return uh.render_mc2(self.ident, "", chk) def _calc_checksum(self, secret): # NOTE: we lazily import des_crypt, @@ -352,15 +370,23 @@ class django_disabled(uh.StaticHandler): claims the special hash string ``"!"`` which Django uses to indicate an account's password has been disabled. - * newly encrypted passwords will hash to ``!``. + * newly encrypted passwords will hash to ``"!"``. * it rejects all passwords. + + .. note:: + + Django 1.6 prepends a randomly generate 40-char alphanumeric string + to each unusuable password. This class recognizes such strings, + but for backwards compatibility, still returns ``"!"``. + + .. versionchanged:: 1.6.2 added Django 1.6 support """ name = "django_disabled" @classmethod def identify(cls, hash): hash = uh.to_unicode_for_identify(hash) - return hash == u("!") + return hash.startswith(u("!")) def _calc_checksum(self, secret): return u("!") diff --git a/passlib/tests/test_ext_django.py b/passlib/tests/test_ext_django.py index f9fd689..d522386 100644 --- a/passlib/tests/test_ext_django.py +++ b/passlib/tests/test_ext_django.py @@ -89,7 +89,8 @@ if has_django: finally: del self.saved_passwords[:] - def save(self): + def save(self, update_fields=None): + # NOTE: ignoring update_fields for test purposes self.saved_passwords.append(self.password) def create_mock_setter(): @@ -107,12 +108,17 @@ def create_mock_setter(): #============================================================================= # work up stock django config #============================================================================= +sample_hashes = {} # override sample hashes used in test cases if has_django14: # have to modify this a little - # all but pbkdf2_sha256 will be deprecated here, # whereas preconfigured passlib policy is more permissive stock_config = django14_context.to_dict() stock_config['deprecated'] = ["django_pbkdf2_sha1", "django_bcrypt"] + stock_config['deprecated'] + if DJANGO_VERSION >= (1,6): + sample_hashes.update( + django_pbkdf2_sha256=("not a password", "pbkdf2_sha256$12000$rpUPFQOVetrY$cEcWG4DjjDpLrDyXnduM+XJUz25U63RcM3//xaFnBnw="), + ) elif has_django1: stock_config = django10_context.to_dict() else: @@ -239,7 +245,9 @@ class _ExtensionSupport(object): # eoc #=================================================================== +# XXX: rename to ExtensionFixture? class _ExtensionTest(TestCase, _ExtensionSupport): + def setUp(self): super(_ExtensionTest, self).setUp() @@ -273,12 +281,23 @@ class DjangoBehaviorTest(_ExtensionTest): return CryptContext._norm_source(self.config) def assert_unusable_password(self, user): - self.assertEqual(user.password, "!") + """check that user object is set to 'unusable password' constant""" + if DJANGO_VERSION >= (1,6): + # 1.6 on adds a random(?) suffix + self.assertTrue(user.password.startswith("!")) + else: + self.assertEqual(user.password, "!") if has_django1 or self.patched: 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. + + :param hash: optionally check it has this exact hash + :param saved: check that mock commit history + for user.password matches this list + """ if hash is UNSET: self.assertNotEqual(user.password, "!") self.assertNotEqual(user.password, None) @@ -318,11 +337,19 @@ class DjangoBehaviorTest(_ExtensionTest): PASS1 = "toomanysecrets" WRONG1 = "letmein" + has_hashers = False + has_identify_hasher = False if has_django14: from passlib.ext.django.utils import hasher_to_passlib_name, passlib_to_hasher_name from django.contrib.auth.hashers import check_password, make_password, is_password_usable - if patched: + if patched or DJANGO_VERSION > (1,5): + # identify_hasher() + # django 1.4 -- not present + # django 1.5 -- present (added in django ticket 18184) + # passlib integration -- present even under 1.4 from django.contrib.auth.hashers import identify_hasher + has_identify_hasher = True + hash_hashers = True else: from django.contrib.auth.models import check_password @@ -361,8 +388,8 @@ class DjangoBehaviorTest(_ExtensionTest): #======================================================= # empty password behavior #======================================================= - if has_django14: - # NOTE: django 1.4 treats empty password as invalid + if (1,4) <= DJANGO_VERSION < (1,6): + # NOTE: django 1.4-1.5 treat empty password as invalid # User.set_password() should set unusable flag user = FakeUser() @@ -408,21 +435,36 @@ class DjangoBehaviorTest(_ExtensionTest): user.set_unusable_password() self.assert_unusable_password(user) - # ensure User.set_password() sets flag + # ensure User.set_password() sets unusable flag user = FakeUser() user.set_password(None) - self.assert_unusable_password(user) + if DJANGO_VERSION < (1,2): + # would set password to hash of "None" + self.assert_valid_password(user) + else: + self.assert_unusable_password(user) # User.check_password() should always fail - self.assertFalse(user.check_password(None)) - self.assertFalse(user.check_password('')) - self.assertFalse(user.check_password(PASS1)) - self.assertFalse(user.check_password(WRONG1)) - self.assert_unusable_password(user) + if DJANGO_VERSION < (1,2): + self.assertTrue(user.check_password(None)) + self.assertTrue(user.check_password('None')) + self.assertFalse(user.check_password('')) + self.assertFalse(user.check_password(PASS1)) + self.assertFalse(user.check_password(WRONG1)) + else: + self.assertFalse(user.check_password(None)) + self.assertFalse(user.check_password('None')) + self.assertFalse(user.check_password('')) + self.assertFalse(user.check_password(PASS1)) + self.assertFalse(user.check_password(WRONG1)) + self.assert_unusable_password(user) # make_password() should also set flag if has_django14: - self.assertEqual(make_password(None), "!") + if DJANGO_VERSION >= (1,6): + self.assertTrue(make_password(None).startswith("!")) + else: + self.assertEqual(make_password(None), "!") # check_password() should return False (didn't handle disabled under 1.3) if has_django14 or patched: @@ -431,7 +473,7 @@ class DjangoBehaviorTest(_ExtensionTest): # identify_hasher() and is_password_usable() should reject it if has_django14: self.assertFalse(is_password_usable(user.password)) - if has_django14 and patched: + if has_identify_hasher: self.assertRaises(ValueError, identify_hasher, user.password) #======================================================= @@ -447,7 +489,10 @@ class DjangoBehaviorTest(_ExtensionTest): else: self.assertRaises(TypeError, user.check_password, PASS1) if has_django1 or patched: - self.assertFalse(user.has_usable_password()) + if DJANGO_VERSION < (1,2): + self.assertTrue(user.has_usable_password()) + else: + self.assertFalse(user.has_usable_password()) # make_password() - n/a @@ -458,32 +503,65 @@ class DjangoBehaviorTest(_ExtensionTest): self.assertRaises(AttributeError, check_password, PASS1, None) # identify_hasher() - error - if has_django14 and patched: + if has_identify_hasher: self.assertRaises(TypeError, identify_hasher, None) #======================================================= - # invalid hash values + # empty & invalid hash values + # NOTE: django 1.5 behavior change due to django ticket 18453 + # NOTE: passlib integration tries to match current django version #======================================================= - for hash in ("", "$789$foo"): + for hash in ("", # empty hash + "$789$foo", # empty identifier + ): # User.set_password() - n/a - # User.check_password() - blank hash causes error + # User.check_password() + # empty + # ----- + # django 1.3 and earlier -- blank hash returns False + # django 1.4 -- blank threw error (fixed in 1.5) + # django 1.5 -- blank hash returns False + # + # invalid + # ------- + # django 1.4 and earlier -- invalid hash threw error (fixed in 1.5) + # django 1.5 -- invalid hash returns False user = FakeUser() user.password = hash - if has_django14 or patched or hash: - self.assertRaises(ValueError, user.check_password, PASS1) - else: - # django 1.3 returns False for empty hashes + if DJANGO_VERSION >= (1,5) or (not hash and DJANGO_VERSION < (1,4)): + # returns False for hash self.assertFalse(user.check_password(PASS1)) - self.assert_valid_password(user, hash) # '' counts as valid for some reason + else: + # throws error for hash + self.assertRaises(ValueError, 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(), []) + + # User.has_usable_password() + # passlib shim for django 0.x -- invalid/empty usable, to match 1.0-1.4 + # django 1.0-1.4 -- invalid/empty usable (fixed in 1.5) + # django 1.5 -- invalid/empty no longer usable + if has_django1 or self.patched: + if DJANGO_VERSION < (1,5): + self.assertTrue(user.has_usable_password()) + else: + self.assertFalse(user.has_usable_password()) # make_password() - n/a - # check_password() - error - self.assertRaises(ValueError, check_password, PASS1, hash) + # check_password() + # django 1.4 and earlier -- invalid/empty hash threw error (fixed in 1.5) + # django 1.5 -- invalid/empty hash now returns False + if DJANGO_VERSION < (1,5): + self.assertRaises(ValueError, check_password, PASS1, hash) + else: + self.assertFalse(check_password(PASS1, hash)) - # identify_hasher() - error - if has_django14 and patched: + # identify_hasher() - throws error + if has_identify_hasher: self.assertRaises(ValueError, identify_hasher, hash) #======================================================= @@ -508,11 +586,14 @@ class DjangoBehaviorTest(_ExtensionTest): if not has_active_backend(handler): assert scheme == "django_bcrypt" continue - while True: - secret, hash = testcase('setUp').get_sample_hash() - if secret: # don't select blank passwords, special under django - break - other = 'letmein' + try: + secret, hash = sample_hashes[scheme] + except KeyError: + while True: + secret, hash = testcase('setUp').get_sample_hash() + if secret: # don't select blank passwords, especially under django 1.4/1.5 + break + other = 'dontletmein' # User.set_password() - n/a @@ -582,7 +663,7 @@ class DjangoBehaviorTest(_ExtensionTest): #------------------------------------------------------- # identify_hasher() recognizes known hash #------------------------------------------------------- - if has_django14 and patched: + if has_identify_hasher: self.assertTrue(is_password_usable(hash)) name = hasher_to_passlib_name(identify_hasher(hash).algorithm) self.assertEqual(name, scheme) @@ -808,18 +889,70 @@ class DjangoExtensionTest(_ExtensionTest): # eoc #=================================================================== +from passlib.context import CryptContext +class ContextWithHook(CryptContext): + """subclass which invokes update_hook(self) before major actions""" + + @staticmethod + def update_hook(self): + pass + + def encrypt(self, *args, **kwds): + self.update_hook(self) + return super(ContextWithHook, self).encrypt(*args, **kwds) + + def verify(self, *args, **kwds): + self.update_hook(self) + return super(ContextWithHook, self).verify(*args, **kwds) + # hack up the some of the real django tests to run w/ extension loaded, # to ensure we mimic their behavior. if has_django14: - from django.contrib.auth.tests.hashers import TestUtilsHashPass as _TestHashers - class HashersTest(_TestHashers, _ExtensionSupport): + from passlib.tests.utils import patchAttr + if DJANGO_VERSION >= (1,6): + from django.contrib.auth.tests import test_hashers as _thmod + else: + from django.contrib.auth.tests import hashers as _thmod + + class HashersTest(_thmod.TestUtilsHashPass, _ExtensionSupport): + """run django's hasher unittests against passlib's extension + and workalike implementations""" def setUp(self): - # omitted orig setup, loading hashers our own way + # NOTE: omitted orig setup, want to install our extension, + # and load hashers through it instead. self.load_extension(PASSLIB_CONTEXT=stock_config, check=False) + from passlib.ext.django.models import password_context + + # update test module to use our versions of some hasher funcs + from django.contrib.auth import hashers + for attr in ["make_password", + "check_password", + "identify_hasher", + "get_hasher"]: + patchAttr(self, _thmod, attr, getattr(hashers, attr)) + + # django 1.5 tests expect empty django_des_crypt salt field + if DJANGO_VERSION > (1,4): + from passlib.hash import django_des_crypt + patchAttr(self, django_des_crypt, "use_duplicate_salt", False) + + # hack: need password_context to keep up to date with hasher.iterations + if DJANGO_VERSION >= (1,6): + def update_hook(self): + rounds = _thmod.get_hasher("pbkdf2_sha256").iterations + self.update( + django_pbkdf2_sha256__min_rounds=rounds, + django_pbkdf2_sha256__max_rounds=rounds, + ) + patchAttr(self, password_context, "__class__", ContextWithHook) + patchAttr(self, password_context, "update_hook", update_hook) + + # omitting this test, since it depends on updated to django hasher settings + test_pbkdf2_upgrade_new_hasher = lambda self: self.skipTest("omitted by passlib") + def tearDown(self): self.unload_extension() super(HashersTest, self).tearDown() - del _TestHashers HashersTest = skipUnless(TEST_MODE("default"), "requires >= 'default' test mode")(HashersTest) diff --git a/passlib/tests/test_handlers.py b/passlib/tests/test_handlers.py index 4845824..293a5da 100644 --- a/passlib/tests/test_handlers.py +++ b/passlib/tests/test_handlers.py @@ -7,6 +7,7 @@ from __future__ import with_statement import hashlib import logging; log = logging.getLogger(__name__) import os +import sys import warnings # site # pkg @@ -37,7 +38,19 @@ def get_handler_case(scheme): name = "%s_%s_test" % (scheme, backend) else: name = "%s_test" % scheme - return globals()[name] + try: + return globals()[name] + except KeyError: + pass + for suffix in ("handlers_django",): + modname = "passlib.tests.test_" + suffix + __import__(modname) + mod = sys.modules[modname] + try: + return getattr(mod, name) + except AttributeError: + pass + raise KeyError("test case %r not found" % name) #============================================================================= # apr md5 crypt @@ -689,259 +702,6 @@ des_crypt_os_crypt_test, des_crypt_builtin_test = \ _des_crypt_test.create_backend_cases(["os_crypt","builtin"]) #============================================================================= -# django -#============================================================================= -class _DjangoHelper(object): - # NOTE: not testing against Django < 1.0 since it doesn't support - # most of these hash formats. - - # flag if hash wasn't added until Django 1.4 - requires14 = False - - def fuzz_verifier_django(self): - from passlib.tests.test_ext_django import has_django1, has_django14 - if not has_django1: - return None - if self.requires14 and not has_django14: - return None - from django.contrib.auth.models import check_password - def verify_django(secret, hash): - "django/check_password" - if has_django14 and not secret: - return "skip" - if self.handler.name == "django_bcrypt" and hash.startswith("bcrypt$$2y$"): - hash = hash.replace("$$2y$", "$$2a$") - return check_password(secret, hash) - return verify_django - - def test_90_django_reference(self): - "run known correct hashes through Django's check_password()" - from passlib.tests.test_ext_django import has_django1, has_django14 - if self.requires14 and not has_django14: - raise self.skipTest("Django >= 1.4 not installed") - if not has_django1: - raise self.skipTest("Django >= 1.0 not installed") - from django.contrib.auth.models import check_password - assert self.known_correct_hashes - for secret, hash in self.iter_known_hashes(): - if has_django14 and not secret: - # django 1.4 rejects empty passwords - self.assertFalse(check_password(secret, hash), - "empty string should not have verified") - continue - self.assertTrue(check_password(secret, hash), - "secret=%r hash=%r failed to verify" % - (secret, hash)) - self.assertFalse(check_password('x' + secret, hash), - "mangled secret=%r hash=%r incorrect verified" % - (secret, hash)) - - def test_91_django_generation(self): - "test against output of Django's make_password()" - from passlib.tests.test_ext_django import has_django14 - if not has_django14: - raise self.skipTest("Django >= 1.4 not installed") - from passlib.utils import tick - from django.contrib.auth.hashers import make_password - name = self.handler.django_name # set for all the django_* handlers - end = tick() + self.max_fuzz_time/2 - while tick() < end: - secret, other = self.get_fuzz_password_pair() - if not secret: # django 1.4 rejects empty passwords. - continue - hash = make_password(secret, hasher=name) - self.assertTrue(self.do_identify(hash)) - self.assertTrue(self.do_verify(secret, hash)) - self.assertFalse(self.do_verify(other, hash)) - -class django_disabled_test(HandlerCase): - "test django_disabled" - handler = hash.django_disabled - is_disabled_handler = True - - known_correct_hashes = [ - # *everything* should hash to "!", and nothing should verify - ("password", "!"), - ("", "!"), - (UPASS_TABLE, "!"), - ] - -class django_des_crypt_test(HandlerCase, _DjangoHelper): - "test django_des_crypt" - handler = hash.django_des_crypt - secret_size = 8 - - known_correct_hashes = [ - # ensures only first two digits of salt count. - ("password", 'crypt$c2$c2M87q...WWcU'), - ("password", 'crypt$c2e86$c2M87q...WWcU'), - ("passwordignoreme", 'crypt$c2.AZ$c2M87q...WWcU'), - - # ensures utf-8 used for unicode - (UPASS_USD, 'crypt$c2e86$c2hN1Bxd6ZiWs'), - (UPASS_TABLE, 'crypt$0.aQs$0.wB.TT0Czvlo'), - (u("hell\u00D6"), "crypt$sa$saykDgk3BPZ9E"), - - # prevent regression of issue 22 - ("foo", 'crypt$MNVY.9ajgdvDQ$MNVY.9ajgdvDQ'), - ] - - known_alternate_hashes = [ - # ensure django 1.4 empty salt field is accepted; - # but that salt field is re-filled (for django 1.0 compatibility) - ('crypt$$c2M87q...WWcU', "password", 'crypt$c2$c2M87q...WWcU'), - ] - - known_unidentified_hashes = [ - 'sha1$aa$bb', - ] - - known_malformed_hashes = [ - # checksum too short - 'crypt$c2$c2M87q', - - # salt must be >2 - 'crypt$f$c2M87q...WWcU', - - # make sure first 2 chars of salt & chk field agree. - 'crypt$ffe86$c2M87q...WWcU', - ] - -class django_salted_md5_test(HandlerCase, _DjangoHelper): - "test django_salted_md5" - handler = hash.django_salted_md5 - - known_correct_hashes = [ - # test extra large salt - ("password", 'md5$123abcdef$c8272612932975ee80e8a35995708e80'), - - # test django 1.4 alphanumeric salt - ("test", 'md5$3OpqnFAHW5CT$54b29300675271049a1ebae07b395e20'), - - # ensures utf-8 used for unicode - (UPASS_USD, 'md5$c2e86$92105508419a81a6babfaecf876a2fa0'), - (UPASS_TABLE, 'md5$d9eb8$01495b32852bffb27cf5d4394fe7a54c'), - ] - - known_unidentified_hashes = [ - 'sha1$aa$bb', - ] - - known_malformed_hashes = [ - # checksum too short - 'md5$aa$bb', - ] - - def fuzz_setting_salt_size(self): - # workaround for django14 regression -- - # 1.4 won't accept hashes with empty salt strings, unlike 1.3 and earlier. - # looks to be fixed in a future release -- https://code.djangoproject.com/ticket/18144 - # for now, we avoid salt_size==0 under 1.4 - handler = self.handler - from passlib.tests.test_ext_django import has_django14 - default = handler.default_salt_size - assert handler.min_salt_size == 0 - lower = 1 if has_django14 else 0 - upper = handler.max_salt_size or default*4 - return randintgauss(lower, upper, default, default*.5) - -class django_salted_sha1_test(HandlerCase, _DjangoHelper): - "test django_salted_sha1" - handler = hash.django_salted_sha1 - - known_correct_hashes = [ - # test extra large salt - ("password",'sha1$123abcdef$e4a1877b0e35c47329e7ed7e58014276168a37ba'), - - # test django 1.4 alphanumeric salt - ("test", 'sha1$bcwHF9Hy8lxS$6b4cfa0651b43161c6f1471ce9523acf1f751ba3'), - - # ensures utf-8 used for unicode - (UPASS_USD, 'sha1$c2e86$0f75c5d7fbd100d587c127ef0b693cde611b4ada'), - (UPASS_TABLE, 'sha1$6d853$ef13a4d8fb57aed0cb573fe9c82e28dc7fd372d4'), - - # generic password - ("MyPassword", 'sha1$54123$893cf12e134c3c215f3a76bd50d13f92404a54d3'), - ] - - known_unidentified_hashes = [ - 'md5$aa$bb', - ] - - known_malformed_hashes = [ - # checksum too short - 'sha1$c2e86$0f75', - ] - - fuzz_setting_salt_size = get_method_function(django_salted_md5_test.fuzz_setting_salt_size) - -class django_pbkdf2_sha256_test(HandlerCase, _DjangoHelper): - "test django_pbkdf2_sha256" - handler = hash.django_pbkdf2_sha256 - requires14 = True - - known_correct_hashes = [ - # - # custom - generated via django 1.4 hasher - # - ('not a password', - 'pbkdf2_sha256$10000$kjVJaVz6qsnJ$5yPHw3rwJGECpUf70daLGhOrQ5+AMxIJdz1c3bqK1Rs='), - (UPASS_TABLE, - 'pbkdf2_sha256$10000$bEwAfNrH1TlQ$OgYUblFNUX1B8GfMqaCYUK/iHyO0pa7STTDdaEJBuY0='), - ] - -class django_pbkdf2_sha1_test(HandlerCase, _DjangoHelper): - "test django_pbkdf2_sha1" - handler = hash.django_pbkdf2_sha1 - requires14 = True - - known_correct_hashes = [ - # - # custom - generated via django 1.4 hashers - # - ('not a password', - 'pbkdf2_sha1$10000$wz5B6WkasRoF$atJmJ1o+XfJxKq1+Nu1f1i57Z5I='), - (UPASS_TABLE, - 'pbkdf2_sha1$10000$KZKWwvqb8BfL$rw5pWsxJEU4JrZAQhHTCO+u0f5Y='), - ] - -class django_bcrypt_test(HandlerCase, _DjangoHelper): - "test django_bcrypt" - handler = hash.django_bcrypt - secret_size = 72 - requires14 = True - - known_correct_hashes = [ - # - # just copied and adapted a few test vectors from bcrypt (above), - # since django_bcrypt is just a wrapper for the real bcrypt class. - # - ('', 'bcrypt$$2a$06$DCq7YPn5Rq63x1Lad4cll.TV4S6ytwfsfvkgY8jIucDrjc8deX1s.'), - ('abcdefghijklmnopqrstuvwxyz', - 'bcrypt$$2a$10$fVH8e28OQRj9tqiDXs1e1uxpsjN0c7II7YPKXua2NAKYvM6iQk7dq'), - (UPASS_TABLE, - 'bcrypt$$2a$05$Z17AXnnlpzddNUvnC6cZNOSwMA/8oNiKnHTHTwLlBijfucQQlHjaG'), - ] - - # NOTE: the following have been cloned from _bcrypt_test() - - def populate_settings(self, kwds): - # speed up test w/ lower rounds - kwds.setdefault("rounds", 4) - super(django_bcrypt_test, self).populate_settings(kwds) - - def fuzz_setting_rounds(self): - # decrease default rounds for fuzz testing to speed up volume. - return randintgauss(5, 8, 6, 1) - - def fuzz_setting_ident(self): - # omit multi-ident tests, only $2a$ counts for this class - return None - -django_bcrypt_test = skipUnless(hash.bcrypt.has_backend(), - "no bcrypt backends available")(django_bcrypt_test) - -#============================================================================= # fshp #============================================================================= class fshp_test(HandlerCase): diff --git a/passlib/tests/test_handlers_django.py b/passlib/tests/test_handlers_django.py new file mode 100644 index 0000000..ac5d1f9 --- /dev/null +++ b/passlib/tests/test_handlers_django.py @@ -0,0 +1,297 @@ +"""passlib.tests.test_handlers_django - tests for passlib hash algorithms""" +#============================================================================= +# imports +#============================================================================= +from __future__ import with_statement +# core +import hashlib +import logging; log = logging.getLogger(__name__) +import os +import warnings +# site +# pkg +from passlib import hash +from passlib.utils import repeat_string +from passlib.utils.compat import irange, PY3, u, get_method_function +from passlib.tests.utils import TestCase, HandlerCase, skipUnless, \ + TEST_MODE, b, catch_warnings, UserHandlerMixin, randintgauss, EncodingHandlerMixin +from passlib.tests.test_handlers import UPASS_WAV, UPASS_USD, UPASS_TABLE +# module + +#============================================================================= +# django +#============================================================================= +class _DjangoHelper(object): + # NOTE: not testing against Django < 1.0 since it doesn't support + # most of these hash formats. + + # flag if hash wasn't added until Django 1.4 + requires14 = False + + def fuzz_verifier_django(self): + from passlib.tests.test_ext_django import DJANGO_VERSION + if DJANGO_VERSION < (1,0): + return None + if self.requires14 and DJANGO_VERSION < (1,4): + return None + from django.contrib.auth.models import check_password + def verify_django(secret, hash): + "django/check_password" + if DJANGO_VERSION >= (1,4) and not secret: + return "skip" + if self.handler.name == "django_bcrypt" and hash.startswith("bcrypt$$2y$"): + hash = hash.replace("$$2y$", "$$2a$") + if DJANGO_VERSION >= (1,5) and self.django_has_encoding_glitch and isinstance(secret, bytes): + # e.g. unsalted_md5 on 1.5 and higher try to combine + # salt + password before encoding to bytes, leading to ascii error. + # this works around that issue. + secret = secret.decode("utf-8") + return check_password(secret, hash) + return verify_django + + def test_90_django_reference(self): + "run known correct hashes through Django's check_password()" + from passlib.tests.test_ext_django import has_django1, has_django14 + if self.requires14 and not has_django14: + raise self.skipTest("Django >= 1.4 not installed") + if not has_django1: + raise self.skipTest("Django >= 1.0 not installed") + from django.contrib.auth.models import check_password + assert self.known_correct_hashes + for secret, hash in self.iter_known_hashes(): + if has_django14 and not secret: + # django 1.4 rejects empty passwords + self.assertFalse(check_password(secret, hash), + "empty string should not have verified") + continue + self.assertTrue(check_password(secret, hash), + "secret=%r hash=%r failed to verify" % + (secret, hash)) + self.assertFalse(check_password('x' + secret, hash), + "mangled secret=%r hash=%r incorrect verified" % + (secret, hash)) + + django_has_encoding_glitch = False + + def test_91_django_generation(self): + "test against output of Django's make_password()" + from passlib.tests.test_ext_django import DJANGO_VERSION + if DJANGO_VERSION < (1,4): + raise self.skipTest("Django >= 1.4 not installed") + from passlib.utils import tick + from django.contrib.auth.hashers import make_password + name = self.handler.django_name # set for all the django_* handlers + end = tick() + self.max_fuzz_time/2 + while tick() < end: + secret, other = self.get_fuzz_password_pair() + if not secret: # django 1.4 rejects empty passwords. + continue + if DJANGO_VERSION >= (1,5) and self.django_has_encoding_glitch and isinstance(secret, bytes): + # e.g. unsalted_md5 on 1.5 and higher try to combine + # salt + password before encoding to bytes, leading to ascii error. + # this works around that issue. + secret = secret.decode("utf-8") + hash = make_password(secret, hasher=name) + self.assertTrue(self.do_identify(hash)) + self.assertTrue(self.do_verify(secret, hash)) + self.assertFalse(self.do_verify(other, hash)) + +class django_disabled_test(HandlerCase): + "test django_disabled" + handler = hash.django_disabled + is_disabled_handler = True + + known_correct_hashes = [ + # *everything* should hash to "!", and nothing should verify + ("password", "!"), + ("", "!"), + (UPASS_TABLE, "!"), + ] + + known_alternate_hashes = [ + # django 1.6 appends random alpnum string + ("!9wa845vn7098ythaehasldkfj", "password", "!"), + ] + +class django_des_crypt_test(HandlerCase, _DjangoHelper): + "test django_des_crypt" + handler = hash.django_des_crypt + secret_size = 8 + + known_correct_hashes = [ + # ensures only first two digits of salt count. + ("password", 'crypt$c2$c2M87q...WWcU'), + ("password", 'crypt$c2e86$c2M87q...WWcU'), + ("passwordignoreme", 'crypt$c2.AZ$c2M87q...WWcU'), + + # ensures utf-8 used for unicode + (UPASS_USD, 'crypt$c2e86$c2hN1Bxd6ZiWs'), + (UPASS_TABLE, 'crypt$0.aQs$0.wB.TT0Czvlo'), + (u("hell\u00D6"), "crypt$sa$saykDgk3BPZ9E"), + + # prevent regression of issue 22 + ("foo", 'crypt$MNVY.9ajgdvDQ$MNVY.9ajgdvDQ'), + ] + + known_alternate_hashes = [ + # ensure django 1.4 empty salt field is accepted; + # but that salt field is re-filled (for django 1.0 compatibility) + ('crypt$$c2M87q...WWcU', "password", 'crypt$c2$c2M87q...WWcU'), + ] + + known_unidentified_hashes = [ + 'sha1$aa$bb', + ] + + known_malformed_hashes = [ + # checksum too short + 'crypt$c2$c2M87q', + + # salt must be >2 + 'crypt$f$c2M87q...WWcU', + + # make sure first 2 chars of salt & chk field agree. + 'crypt$ffe86$c2M87q...WWcU', + ] + +class django_salted_md5_test(HandlerCase, _DjangoHelper): + "test django_salted_md5" + handler = hash.django_salted_md5 + + django_has_encoding_glitch = True + + known_correct_hashes = [ + # test extra large salt + ("password", 'md5$123abcdef$c8272612932975ee80e8a35995708e80'), + + # test django 1.4 alphanumeric salt + ("test", 'md5$3OpqnFAHW5CT$54b29300675271049a1ebae07b395e20'), + + # ensures utf-8 used for unicode + (UPASS_USD, 'md5$c2e86$92105508419a81a6babfaecf876a2fa0'), + (UPASS_TABLE, 'md5$d9eb8$01495b32852bffb27cf5d4394fe7a54c'), + ] + + known_unidentified_hashes = [ + 'sha1$aa$bb', + ] + + known_malformed_hashes = [ + # checksum too short + 'md5$aa$bb', + ] + + def fuzz_setting_salt_size(self): + # workaround for django14 regression -- + # 1.4 won't accept hashes with empty salt strings, unlike 1.3 and earlier. + # looks to be fixed in a future release -- https://code.djangoproject.com/ticket/18144 + # for now, we avoid salt_size==0 under 1.4 + handler = self.handler + from passlib.tests.test_ext_django import has_django14 + default = handler.default_salt_size + assert handler.min_salt_size == 0 + lower = 1 if has_django14 else 0 + upper = handler.max_salt_size or default*4 + return randintgauss(lower, upper, default, default*.5) + +class django_salted_sha1_test(HandlerCase, _DjangoHelper): + "test django_salted_sha1" + handler = hash.django_salted_sha1 + + django_has_encoding_glitch = True + + known_correct_hashes = [ + # test extra large salt + ("password",'sha1$123abcdef$e4a1877b0e35c47329e7ed7e58014276168a37ba'), + + # test django 1.4 alphanumeric salt + ("test", 'sha1$bcwHF9Hy8lxS$6b4cfa0651b43161c6f1471ce9523acf1f751ba3'), + + # ensures utf-8 used for unicode + (UPASS_USD, 'sha1$c2e86$0f75c5d7fbd100d587c127ef0b693cde611b4ada'), + (UPASS_TABLE, 'sha1$6d853$ef13a4d8fb57aed0cb573fe9c82e28dc7fd372d4'), + + # generic password + ("MyPassword", 'sha1$54123$893cf12e134c3c215f3a76bd50d13f92404a54d3'), + ] + + known_unidentified_hashes = [ + 'md5$aa$bb', + ] + + known_malformed_hashes = [ + # checksum too short + 'sha1$c2e86$0f75', + ] + + fuzz_setting_salt_size = get_method_function(django_salted_md5_test.fuzz_setting_salt_size) + +class django_pbkdf2_sha256_test(HandlerCase, _DjangoHelper): + "test django_pbkdf2_sha256" + handler = hash.django_pbkdf2_sha256 + requires14 = True + + known_correct_hashes = [ + # + # custom - generated via django 1.4 hasher + # + ('not a password', + 'pbkdf2_sha256$10000$kjVJaVz6qsnJ$5yPHw3rwJGECpUf70daLGhOrQ5+AMxIJdz1c3bqK1Rs='), + (UPASS_TABLE, + 'pbkdf2_sha256$10000$bEwAfNrH1TlQ$OgYUblFNUX1B8GfMqaCYUK/iHyO0pa7STTDdaEJBuY0='), + ] + +class django_pbkdf2_sha1_test(HandlerCase, _DjangoHelper): + "test django_pbkdf2_sha1" + handler = hash.django_pbkdf2_sha1 + requires14 = True + + known_correct_hashes = [ + # + # custom - generated via django 1.4 hashers + # + ('not a password', + 'pbkdf2_sha1$10000$wz5B6WkasRoF$atJmJ1o+XfJxKq1+Nu1f1i57Z5I='), + (UPASS_TABLE, + 'pbkdf2_sha1$10000$KZKWwvqb8BfL$rw5pWsxJEU4JrZAQhHTCO+u0f5Y='), + ] + +class django_bcrypt_test(HandlerCase, _DjangoHelper): + "test django_bcrypt" + handler = hash.django_bcrypt + secret_size = 72 + requires14 = True + + known_correct_hashes = [ + # + # just copied and adapted a few test vectors from bcrypt (above), + # since django_bcrypt is just a wrapper for the real bcrypt class. + # + ('', 'bcrypt$$2a$06$DCq7YPn5Rq63x1Lad4cll.TV4S6ytwfsfvkgY8jIucDrjc8deX1s.'), + ('abcdefghijklmnopqrstuvwxyz', + 'bcrypt$$2a$10$fVH8e28OQRj9tqiDXs1e1uxpsjN0c7II7YPKXua2NAKYvM6iQk7dq'), + (UPASS_TABLE, + 'bcrypt$$2a$05$Z17AXnnlpzddNUvnC6cZNOSwMA/8oNiKnHTHTwLlBijfucQQlHjaG'), + ] + + # NOTE: the following have been cloned from _bcrypt_test() + + def populate_settings(self, kwds): + # speed up test w/ lower rounds + kwds.setdefault("rounds", 4) + super(django_bcrypt_test, self).populate_settings(kwds) + + def fuzz_setting_rounds(self): + # decrease default rounds for fuzz testing to speed up volume. + return randintgauss(5, 8, 6, 1) + + def fuzz_setting_ident(self): + # omit multi-ident tests, only $2a$ counts for this class + return None + +django_bcrypt_test = skipUnless(hash.bcrypt.has_backend(), + "no bcrypt backends available")(django_bcrypt_test) + +#============================================================================= +# eof +#============================================================================= diff --git a/passlib/tests/utils.py b/passlib/tests/utils.py index 29254c8..2965689 100644 --- a/passlib/tests/utils.py +++ b/passlib/tests/utils.py @@ -203,6 +203,22 @@ def quicksleep(delay): #============================================================================= # custom test harness #============================================================================= + +def patchAttr(test, obj, attr, value): + """monkeypatch object value, restoring original on cleanup""" + try: + orig = getattr(obj, attr) + except AttributeError: + def cleanup(): + try: + delattr(obj, attr) + except AttributeError: + pass + test.addCleanup(cleanup) + else: + test.addCleanup(setattr, obj, attr, orig) + setattr(obj, attr, value) + class TestCase(_TestCase): """passlib-specific test case class @@ -23,9 +23,8 @@ # testing of m2crypto integration - done in py27 test # # testing of django integration - split across various cpython tests: -# py25 - tests django 1.3 -# py26 - tests no django -# py27 - tests latest django +# py27,py33 - tests latest django +# djangoXX - tests specific django versions # # testing of bcrypt backends - split across various cpython tests: # py25 - tests builtin bcrypt @@ -36,8 +35,8 @@ # global config #=========================================================================== [tox] -minversion=1.3 -envlist = py27,py32,py25,py26,py31,py33,pypy1.5,pypy16,pypy17,pypy18,pypy19,jython,gae25,gae27 +minversion=1.4 +envlist = py27,py33,py25,py26,py31,py32,pypy,pypy3,django12,django13,django14,django15,jython,gae25,gae27 #=========================================================================== # stock CPython VMs @@ -54,13 +53,14 @@ deps = unittest2 [testenv:py25] +# NOTE: unittest2 omitted, to test unittest backport code deps = nose coverage - # unittest2 omitted, to test backport code - django<1.4 [testenv:py27] +# NOTE: M2Crypto requires swig & libssl-dev, +# a number of packages required C compiler & python-dev deps = nose coverage @@ -82,28 +82,75 @@ deps = unittest2py3k [testenv:py33] +# TODO: test bcrypt library w/ py3 compatibility deps = nose coverage unittest2py3k + django #=========================================================================== -# PyPy VM - all releases currently target Python 2.7 +# django integration testing #=========================================================================== -[testenv:pypy15] -basepython = pypy1.5 +[testenv:django12] +deps = + nose + unittest2 + django<1.3 +commands = + nosetests {posargs:passlib.tests.test_ext_django passlib.tests.test_handlers_django} -[testenv:pypy16] -basepython = pypy1.6 +[testenv:django13] +deps = + nose + unittest2 + django<1.4 +commands = + nosetests {posargs:passlib.tests.test_ext_django passlib.tests.test_handlers_django} -[testenv:pypy17] -basepython = pypy1.7 +[testenv:django14] +deps = + nose + unittest2 + django<1.5 +commands = + nosetests {posargs:passlib.tests.test_ext_django passlib.tests.test_handlers_django} -[testenv:pypy18] -basepython = pypy1.8 +[testenv:django15] +deps = + nose + unittest2 + django<1.6 +commands = + nosetests {posargs:passlib.tests.test_ext_django passlib.tests.test_handlers_django} + +[testenv:django] +deps = + nose + unittest2 + django +commands = + nosetests {posargs:passlib.tests.test_ext_django passlib.tests.test_handlers_django} + +[testenv:django-py3] +basepython = python3 +deps = + nose + unittest2py3k + django +commands = + nosetests {posargs:passlib.tests.test_ext_django passlib.tests.test_handlers_django} -[testenv:pypy19] -basepython = pypy1.9 +#=========================================================================== +# PyPy VM - all releases currently target Python 2.7 +#=========================================================================== +[testenv:pypy] +# pypy (as of v1.6 - v2.2) targets Python 2.7 +basepython = pypy + +[testenv:pypy3] +# pypy3 (as of v2.1b1) targets Python 3.2 +basepython = pypy3 #=========================================================================== # Jython - no special directives, currently same as py25 @@ -112,7 +159,11 @@ basepython = pypy1.9 #=========================================================================== # Google App Engine integration #=========================================================================== + [testenv:gae25] +# NOTE: google is deprecating py25 support, per +# https://developers.google.com/appengine/docs/python/python25/diff27 +# and so this test can probably be removed sometime after 2014-01-01 basepython = python2.5 deps = # FIXME: getting all kinds of errors when using nosegae 0.2.0 :( |