summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorEli Collins <elic@assurancetechnologies.com>2020-10-08 12:57:55 -0400
committerEli Collins <elic@assurancetechnologies.com>2020-10-08 12:57:55 -0400
commit969eb2cf0721d8395f06209e16ab52955746aa30 (patch)
tree0bedc6474f504966bb4eb67b28ba071fe887e391
parentdf3f02025c00a7623c6771c454cb3dfb2c29caa5 (diff)
parentcc21426e0f7a93e5dbd2c66d9752123924a1a4dc (diff)
downloadpasslib-969eb2cf0721d8395f06209e16ab52955746aa30.tar.gz
Merge from stable
As part of merge: * various: reverted the py26 compat fixes from rev 5e2f92012412 * test utils: stripped out "has_real_subtest" compat from rev c732a9e2a582, since now on py35+, which always has .subTest() method
-rw-r--r--docs/history/1.7.rst27
-rw-r--r--docs/lib/passlib.ext.django.rst20
-rw-r--r--passlib/apps.py64
-rw-r--r--passlib/context.py3
-rw-r--r--passlib/crypto/digest.py7
-rw-r--r--passlib/exc.py14
-rw-r--r--passlib/ext/django/utils.py70
-rw-r--r--passlib/tests/test_context.py32
-rw-r--r--passlib/tests/test_ext_django.py693
-rw-r--r--passlib/tests/utils.py51
-rw-r--r--tox.ini30
11 files changed, 710 insertions, 301 deletions
diff --git a/docs/history/1.7.rst b/docs/history/1.7.rst
index 64fe51a..8a10f2a 100644
--- a/docs/history/1.7.rst
+++ b/docs/history/1.7.rst
@@ -10,6 +10,33 @@ Passlib 1.7
and will require Python >= 3.5. The 1.7 series will be the last
to support Python 2.7. (See :issue:`119` for rationale).
+**1.7.4** (NOT YET RELEASED)
+============================
+
+Bugfixes
+--------
+
+* Fixed some Python 2.6 errors from last release (:issue:`128`)
+
+Other Changes
+-------------
+
+* :mod:`passlib.ext.django` -- updated tests to pass for Django 1.8 - 3.1 (:issue:`98`);
+ along with some internal refactoring of the test classes.
+
+* .. py:currentmodule:: passlib.context
+
+ :class:`CryptContext` will now throw :exc:`~passlib.exc.UnknownHashError` when it can't identify
+ a hash provided to methods such as :meth:`!CryptContext.verify`.
+ Previously it would throw a generic :exc:`ValueError`.
+
+
+Deprecations
+------------
+
+* :mod:`passlib.ext.django`: This extension will require Django 2.2 or newer as of Passlib 1.8.
+
+
**1.7.3** (2020-10-06)
======================
diff --git a/docs/lib/passlib.ext.django.rst b/docs/lib/passlib.ext.django.rst
index 51097cc..747c5e7 100644
--- a/docs/lib/passlib.ext.django.rst
+++ b/docs/lib/passlib.ext.django.rst
@@ -6,12 +6,6 @@
:mod:`passlib.ext.django` - Django Password Hashing Plugin
==========================================================
-.. versionadded:: 1.6
-
-.. versionchanged:: 1.7
-
- As of Passlib 1.7, this module requires Django 1.8 or newer.
-
.. rst-class:: float-center without-title
.. warning::
@@ -49,9 +43,19 @@ of uses:
This plugin should be considered "release candidate" quality.
It works, and has good unittest coverage, but has seen only
limited real-world use. Please report any issues.
- It has been tested with Django 1.8 - 1.9.
+ It has been tested with Django 1.8 - 3.1.
+
+.. versionadded:: 1.6
+
+.. versionchanged:: 1.7
+
+ Support for Django 1.0 - 1.7 was dropped; now requires Django 1.8 or newer.
+
+.. rst-class:: without-title
+
+.. warning::
- (Support for Django 1.0 - 1.7 was dropped after Passlib 1.6).
+ As of Passlib 1.8, this module will require Django 2.2 or newer.
Installation
=============
diff --git a/passlib/apps.py b/passlib/apps.py
index 38c3500..e8a2555 100644
--- a/passlib/apps.py
+++ b/passlib/apps.py
@@ -86,9 +86,17 @@ custom_app_context = LazyCryptContext(
#=============================================================================
# django
#=============================================================================
+
+#-----------------------------------------------------------------------
+# 1.0
+#-----------------------------------------------------------------------
+
_django10_schemes = [
- "django_salted_sha1", "django_salted_md5", "django_des_crypt",
- "hex_md5", "django_disabled",
+ "django_salted_sha1",
+ "django_salted_md5",
+ "django_des_crypt",
+ "hex_md5",
+ "django_disabled",
]
django10_context = LazyCryptContext(
@@ -97,28 +105,60 @@ django10_context = LazyCryptContext(
deprecated=["hex_md5"],
)
-_django14_schemes = ["django_pbkdf2_sha256", "django_pbkdf2_sha1",
- "django_bcrypt"] + _django10_schemes
+#-----------------------------------------------------------------------
+# 1.4
+#-----------------------------------------------------------------------
+
+_django14_schemes = [
+ "django_pbkdf2_sha256",
+ "django_pbkdf2_sha1",
+ "django_bcrypt"
+] + _django10_schemes
+
django14_context = LazyCryptContext(
schemes=_django14_schemes,
deprecated=_django10_schemes,
)
-_django16_schemes = _django14_schemes[:]
+#-----------------------------------------------------------------------
+# 1.6
+#-----------------------------------------------------------------------
+
+_django16_schemes = list(_django14_schemes)
_django16_schemes.insert(1, "django_bcrypt_sha256")
django16_context = LazyCryptContext(
schemes=_django16_schemes,
deprecated=_django10_schemes,
)
-django110_context = LazyCryptContext(
- schemes=["django_pbkdf2_sha256", "django_pbkdf2_sha1",
- "django_argon2", "django_bcrypt", "django_bcrypt_sha256",
- "django_disabled"],
-)
+#-----------------------------------------------------------------------
+# 1.10
+#-----------------------------------------------------------------------
+
+_django_110_schemes = [
+ "django_pbkdf2_sha256",
+ "django_pbkdf2_sha1",
+ "django_argon2",
+ "django_bcrypt",
+ "django_bcrypt_sha256",
+ "django_disabled",
+]
+django110_context = LazyCryptContext(schemes=_django_110_schemes)
+
+#-----------------------------------------------------------------------
+# 2.1
+#-----------------------------------------------------------------------
+
+_django21_schemes = list(_django_110_schemes)
+_django21_schemes.remove("django_bcrypt")
+django21_context = LazyCryptContext(schemes=_django21_schemes)
+
+#-----------------------------------------------------------------------
+# latest
+#-----------------------------------------------------------------------
-# this will always point to latest version
-django_context = django110_context
+# this will always point to latest version in passlib
+django_context = django21_context
#=============================================================================
# ldap
diff --git a/passlib/context.py b/passlib/context.py
index f210a1f..21865a0 100644
--- a/passlib/context.py
+++ b/passlib/context.py
@@ -12,6 +12,7 @@ import time
from warnings import warn
# site
# pkg
+from passlib import exc
from passlib.exc import ExpectedStringError, ExpectedTypeError, PasslibConfigWarning
from passlib.registry import get_crypt_handler, _validate_handler_name
from passlib.utils import (handlers as uh, to_bytes,
@@ -611,7 +612,7 @@ class _CryptConfig(object):
elif not self.schemes:
raise KeyError("no crypt algorithms supported")
else:
- raise ValueError("hash could not be identified")
+ raise exc.UnknownHashError("hash could not be identified")
@memoized_property
def disabled_record(self):
diff --git a/passlib/crypto/digest.py b/passlib/crypto/digest.py
index fce6e19..c1b9769 100644
--- a/passlib/crypto/digest.py
+++ b/passlib/crypto/digest.py
@@ -118,6 +118,11 @@ _fallback_info = {
def _gen_fallback_info():
+ """
+ internal helper used to generate ``_fallback_info`` dict.
+ currently only run manually to update the above list;
+ not invoked at runtime.
+ """
out = {}
for alg in sorted(hashlib.algorithms_available | {"md4"}):
info = lookup_hash(alg)
@@ -467,7 +472,7 @@ class HashInfo(SequenceMixin):
helper that installs stub constructor which throws specified error <msg>.
"""
def const(source=b""):
- raise exc.UnknownHashError(name, message=msg)
+ raise exc.UnknownHashError(msg, name)
if required:
# if caller only wants supported digests returned,
# just throw error immediately...
diff --git a/passlib/exc.py b/passlib/exc.py
index 262eb12..29ef8af 100644
--- a/passlib/exc.py
+++ b/passlib/exc.py
@@ -193,17 +193,23 @@ class UnknownHashError(ValueError):
As of version 1.7.3, this may also be raised if hash algorithm is known,
but has been disabled due to FIPS mode (message will include phrase "disabled for fips").
+ As of version 1.7.4, this may be raised if a :class:`~passlib.context.CryptContext`
+ is unable to identify the algorithm used by a password hash.
+
.. versionadded:: 1.7
.. versionchanged: 1.7.3
added 'message' argument.
+
+ .. versionchanged:: 1.7.4
+ altered call signature.
"""
- def __init__(self, name, message=None):
- self.name = name
+ def __init__(self, message=None, value=None):
+ self.value = value
if message is None:
- message = "unknown hash algorithm: %r" % name
+ message = "unknown hash algorithm: %r" % value
self.message = message
- ValueError.__init__(self, name, message)
+ ValueError.__init__(self, message, value)
def __str__(self):
return self.message
diff --git a/passlib/ext/django/utils.py b/passlib/ext/django/utils.py
index 001df4e..d25a501 100644
--- a/passlib/ext/django/utils.py
+++ b/passlib/ext/django/utils.py
@@ -27,13 +27,29 @@ __all__ = [
"DJANGO_VERSION",
"MIN_DJANGO_VERSION",
"get_preset_config",
- "get_django_hasher",
+ "quirks",
]
#: minimum version supported by passlib.ext.django
MIN_DJANGO_VERSION = (1, 8)
#=============================================================================
+# quirk detection
+#=============================================================================
+
+class quirks:
+
+ #: django check_password() started throwing error on encoded=None
+ #: (really identify_hasher did)
+ none_causes_check_password_error = DJANGO_VERSION >= (2, 1)
+
+ #: django is_usable_password() started returning True for password = {None, ""} values.
+ empty_is_usable_password = DJANGO_VERSION >= (2, 1)
+
+ #: django is_usable_password() started returning True for non-hash strings in 2.1
+ invalid_is_usable_password = DJANGO_VERSION >= (2, 1)
+
+#=============================================================================
# default policies
#=============================================================================
@@ -236,6 +252,13 @@ class DjangoTranslator(object):
md5="MD5PasswordHasher",
)
+ if DJANGO_VERSION > (2, 1):
+ # present but disabled by default as of django 2.1; not sure when added,
+ # so not listing it by default.
+ _builtin_django_hashers.update(
+ bcrypt="BCryptPasswordHasher",
+ )
+
def _create_django_hasher(self, django_name):
"""
helper to create new django hasher by name.
@@ -245,17 +268,22 @@ class DjangoTranslator(object):
module = sys.modules.get("passlib.ext.django.models")
if module is None or not module.adapter.patched:
from django.contrib.auth.hashers import get_hasher
- return get_hasher(django_name)
-
- # We've patched django's get_hashers(), so calling django's get_hasher()
- # or get_hashers_by_algorithm() would only land us back here.
- # As non-ideal workaround, have to use original get_hashers(),
- get_hashers = module.adapter._manager.getorig("django.contrib.auth.hashers:get_hashers").__wrapped__
- for hasher in get_hashers():
- if hasher.algorithm == django_name:
- return hasher
-
- # hardcode a few for cases where get_hashers() look won't work.
+ try:
+ return get_hasher(django_name)
+ except ValueError as err:
+ if not str(err).startswith("Unknown password hashing algorithm"):
+ raise
+ else:
+ # We've patched django's get_hashers(), so calling django's get_hasher()
+ # or get_hashers_by_algorithm() would only land us back here.
+ # As non-ideal workaround, have to use original get_hashers(),
+ get_hashers = module.adapter._manager.getorig("django.contrib.auth.hashers:get_hashers").__wrapped__
+ for hasher in get_hashers():
+ if hasher.algorithm == django_name:
+ return hasher
+
+ # hardcode a few for cases where get_hashers() lookup won't work
+ # (mainly, hashers that are present in django, but disabled by their default config)
path = self._builtin_django_hashers.get(django_name)
if path:
if "." not in path:
@@ -546,12 +574,20 @@ class DjangoContextAdapter(DjangoTranslator):
"""
# XXX: this currently ignores "preferred" keyword, since its purpose
# was for hash migration, and that's handled by the context.
+ # XXX: honor "none_causes_check_password_error" quirk for django 2.2+?
+ # seems safer to return False.
if password is None or not self.is_password_usable(encoded):
return False
# verify password
context = self.context
- correct = context.verify(password, encoded)
+ try:
+ correct = context.verify(password, encoded)
+ except exc.UnknownHashError:
+ # As of django 1.5, unidentifiable hashes returns False
+ # (side-effect of django issue 18453)
+ return False
+
if not (correct and setter):
return correct
@@ -592,8 +628,12 @@ class DjangoContextAdapter(DjangoTranslator):
if not self.is_password_usable(hash):
return False
cat = self.get_user_category(user)
- ok, new_hash = self.context.verify_and_update(password, hash,
- category=cat)
+ try:
+ ok, new_hash = self.context.verify_and_update(password, hash, category=cat)
+ except exc.UnknownHashError:
+ # As of django 1.5, unidentifiable hashes returns False
+ # (side-effect of django issue 18453)
+ return False
if ok and new_hash is not None:
# migrate to new hash if needed.
user.password = new_hash
diff --git a/passlib/tests/test_context.py b/passlib/tests/test_context.py
index ea11f4e..d7f90c6 100644
--- a/passlib/tests/test_context.py
+++ b/passlib/tests/test_context.py
@@ -1612,6 +1612,7 @@ sha512_crypt__min_rounds = 45000
#
# init ref info
#
+ from passlib.exc import UnknownHashError
from passlib.hash import md5_crypt, unix_disabled
ctx = CryptContext(["des_crypt"])
@@ -1649,18 +1650,13 @@ sha512_crypt__min_rounds = 45000
# test w/o disabled hash support
self.assertTrue(ctx.is_enabled(h_ref))
- HASH_NOT_IDENTIFIED = "hash could not be identified"
- self.assertRaisesRegex(ValueError, HASH_NOT_IDENTIFIED,
- ctx.is_enabled, h_other)
- self.assertRaisesRegex(ValueError, HASH_NOT_IDENTIFIED,
- ctx.is_enabled, h_dis)
- self.assertRaisesRegex(ValueError, HASH_NOT_IDENTIFIED,
- ctx.is_enabled, h_dis_ref)
+ self.assertRaises(UnknownHashError, ctx.is_enabled, h_other)
+ self.assertRaises(UnknownHashError, ctx.is_enabled, h_dis)
+ self.assertRaises(UnknownHashError, ctx.is_enabled, h_dis_ref)
# test w/ disabled hash support
self.assertTrue(ctx2.is_enabled(h_ref))
- self.assertRaisesRegex(ValueError, HASH_NOT_IDENTIFIED,
- ctx.is_enabled, h_other)
+ self.assertRaises(UnknownHashError, ctx.is_enabled, h_other)
self.assertFalse(ctx2.is_enabled(h_dis))
self.assertFalse(ctx2.is_enabled(h_dis_ref))
@@ -1669,24 +1665,18 @@ sha512_crypt__min_rounds = 45000
#
# test w/o disabled hash support
- self.assertRaisesRegex(ValueError, HASH_NOT_IDENTIFIED,
- ctx.enable, "")
+ self.assertRaises(UnknownHashError, ctx.enable, "")
self.assertRaises(TypeError, ctx.enable, None)
self.assertEqual(ctx.enable(h_ref), h_ref)
- self.assertRaisesRegex(ValueError, HASH_NOT_IDENTIFIED,
- ctx.enable, h_other)
- self.assertRaisesRegex(ValueError, HASH_NOT_IDENTIFIED,
- ctx.enable, h_dis)
- self.assertRaisesRegex(ValueError, HASH_NOT_IDENTIFIED,
- ctx.enable, h_dis_ref)
+ self.assertRaises(UnknownHashError, ctx.enable, h_other)
+ self.assertRaises(UnknownHashError, ctx.enable, h_dis)
+ self.assertRaises(UnknownHashError, ctx.enable, h_dis_ref)
# test w/ disabled hash support
- self.assertRaisesRegex(ValueError, HASH_NOT_IDENTIFIED,
- ctx.enable, "")
+ self.assertRaises(UnknownHashError, ctx.enable, "")
self.assertRaises(TypeError, ctx2.enable, None)
self.assertEqual(ctx2.enable(h_ref), h_ref)
- self.assertRaisesRegex(ValueError, HASH_NOT_IDENTIFIED,
- ctx2.enable, h_other)
+ self.assertRaises(UnknownHashError, ctx2.enable, h_other)
self.assertRaisesRegex(ValueError, "cannot restore original hash",
ctx2.enable, h_dis)
self.assertEqual(ctx2.enable(h_dis_ref), h_ref)
diff --git a/passlib/tests/test_ext_django.py b/passlib/tests/test_ext_django.py
index b43afea..4a2d4c9 100644
--- a/passlib/tests/test_ext_django.py
+++ b/passlib/tests/test_ext_django.py
@@ -12,7 +12,7 @@ from passlib import apps as _apps, exc, registry
from passlib.apps import django10_context, django14_context, django16_context
from passlib.context import CryptContext
from passlib.ext.django.utils import (
- DJANGO_VERSION, MIN_DJANGO_VERSION, DjangoTranslator,
+ DJANGO_VERSION, MIN_DJANGO_VERSION, DjangoTranslator, quirks,
)
from passlib.utils.compat import get_method_function
from passlib.utils.decor import memoized_property
@@ -23,7 +23,10 @@ from passlib.tests.test_handlers import get_handler_case
__all__ = [
"DjangoBehaviorTest",
"ExtensionBehaviorTest",
+ "DjangoExtensionTest",
+
"_ExtensionSupport",
+ "_ExtensionTest",
]
#=============================================================================
# configure django settings for testcases
@@ -59,6 +62,11 @@ if has_min_django:
from django.apps import apps
apps.populate(["django.contrib.contenttypes", "django.contrib.auth"])
+# log a warning if tested w/ newer version.
+# NOTE: this is mainly here as place to mark what version it was run against before release.
+if DJANGO_VERSION >= (3, 2):
+ log.info("this release hasn't been tested against Django %r", DJANGO_VERSION)
+
#=============================================================================
# support funcs
#=============================================================================
@@ -132,28 +140,58 @@ def check_django_hasher_has_backend(name):
# work up stock django config
#=============================================================================
+def _modify_django_config(kwds, sha_rounds=None):
+ """
+ helper to build django CryptContext config matching expected setup for stock django deploy.
+ :param kwds:
+ :param sha_rounds:
+ :return:
+ """
+ # make sure we have dict
+ if hasattr(kwds, "to_dict"):
+ # type: CryptContext
+ kwds = kwds.to_dict()
+
+ # update defaults
+ kwds.update(
+ # TODO: push this to passlib.apps django contexts
+ deprecated="auto",
+ )
+
+ # fill in default rounds for current django version, so our sample hashes come back
+ # unchanged, instead of being upgraded in-place by check_password().
+ if sha_rounds is None and has_min_django:
+ from django.contrib.auth.hashers import PBKDF2PasswordHasher
+ sha_rounds = PBKDF2PasswordHasher.iterations
+
+ # modify rounds
+ if sha_rounds:
+ kwds.update(
+ django_pbkdf2_sha1__default_rounds=sha_rounds,
+ django_pbkdf2_sha256__default_rounds=sha_rounds,
+ )
+
+ return kwds
+
+#----------------------------------------------------
# build config dict that matches stock django
-# XXX: move these to passlib.apps?
-if DJANGO_VERSION >= (1, 11):
- stock_config = _apps.django110_context.to_dict()
- stock_rounds = 36000
+#----------------------------------------------------
+
+# XXX: replace this with code that interrogates default django config directly?
+# could then separate out "validation of djangoXX_context objects"
+# and "validation that individual hashers match django".
+# or maybe add a "get_django_context(django_version)" helper to passlib.apps?
+if DJANGO_VERSION >= (2, 1):
+ stock_config = _modify_django_config(_apps.django21_context)
elif DJANGO_VERSION >= (1, 10):
- stock_config = _apps.django110_context.to_dict()
- stock_rounds = 30000
-elif DJANGO_VERSION >= (1, 9):
- stock_config = _apps.django16_context.to_dict()
- stock_rounds = 24000
-else: # 1.8
- stock_config = _apps.django16_context.to_dict()
- stock_rounds = 20000
-
-stock_config.update(
- deprecated="auto",
- django_pbkdf2_sha1__default_rounds=stock_rounds,
- django_pbkdf2_sha256__default_rounds=stock_rounds,
-)
+ stock_config = _modify_django_config(_apps.django110_context)
+else:
+ # assert DJANGO_VERSION >= (1, 8)
+ stock_config = _modify_django_config(_apps.django16_context)
+#----------------------------------------------------
# override sample hashes used in test cases
+#----------------------------------------------------
from passlib.hash import django_pbkdf2_sha256
sample_hashes = dict(
django_pbkdf2_sha256=("not a password", django_pbkdf2_sha256
@@ -164,11 +202,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.
@@ -205,8 +248,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")
@@ -223,7 +269,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")
@@ -248,9 +296,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")
@@ -262,7 +314,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:
@@ -277,8 +331,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().setUp()
@@ -296,13 +361,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
@@ -310,22 +402,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, "!")
@@ -337,55 +437,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)
@@ -400,9 +499,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()
@@ -415,14 +524,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()
@@ -452,175 +573,270 @@ 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
- # FIXME: at some point past 1.8, some of these django started handler None differently;
- # and/or throwing TypeError. need to investigate when that change occurred;
- # update these tests, and maybe passlib.ext.django as well.
user = FakeUser()
user.password = None
- self.assertFalse(user.check_password(PASS1))
- self.assertFalse(user.has_usable_password())
+ if quirks.none_causes_check_password_error and not patched:
+ # django 2.1+
+ self.assertRaises(TypeError, user.check_password, PASS1)
+ else:
+ self.assertFalse(user.check_password(PASS1))
+
+ self.assertEqual(user.has_usable_password(),
+ quirks.empty_is_usable_password)
+
+ # TODO: is_password_usable()
# make_password() - n/a
# check_password() - error
- self.assertFalse(check_password(PASS1, None))
+ if quirks.none_causes_check_password_error and not patched:
+ self.assertRaises(TypeError, check_password, PASS1, None)
+ else:
+ self.assertFalse(check_password(PASS1, None))
# identify_hasher() - error
self.assertRaises(TypeError, identify_hasher, None)
- #=======================================================
- # 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 ("", # empty hash
- "$789$foo", # empty identifier
- ):
- # User.set_password() - n/a
-
- # User.check_password()
- # As of django 1.5, blank OR invalid hash returns False
- user = FakeUser()
- user.password = hash
- self.assertFalse(user.check_password(PASS1))
+ 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,
+ )
- # verify hash wasn't changed/upgraded during check_password() call
- self.assertEqual(user.password, hash)
- self.assertEqual(user.pop_saved_passwords(), [])
+ # User.set_password() - n/a
- # User.has_usable_password()
- self.assertFalse(user.has_usable_password())
+ # User.check_password()
+ # As of django 1.5, blank hash returns False (django issue 18453)
+ user = FakeUser()
+ user.password = ""
+ self.assertFalse(user.check_password(PASS1))
- # make_password() - n/a
+ # verify hash wasn't changed/upgraded during check_password() call
+ self.assertEqual(user.password, "")
+ self.assertEqual(user.pop_saved_passwords(), [])
- # check_password()
- self.assertFalse(check_password(PASS1, hash))
+ # User.has_usable_password()
+ self.assertEqual(user.has_usable_password(), quirks.empty_is_usable_password)
- # identify_hasher() - throws error
- self.assertRaises(ValueError, identify_hasher, hash)
+ # TODO: is_password_usable()
- #=======================================================
- # run through all the schemes in the context,
- # testing various bits of per-scheme behavior.
- #=======================================================
- for scheme in ctx.schemes():
+ # make_password() - n/a
- #
- # TODO: break this loop up into separate parameterized tests.
- #
+ # check_password()
+ self.assertFalse(check_password(PASS1, ""))
- #-------------------------------------------------------
- # setup constants & imports, pick a sample secret/hash combo
- #-------------------------------------------------------
+ # identify_hasher() - throws error
+ self.assertRaises(ValueError, identify_hasher, "")
- 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 test_invalid_hash_values(self):
+ """
+ test how methods handle invalid hash values.
+ """
+ for hash in [
+ "$789$foo", # empty identifier
+ ]:
+ with self.subTest(hash=hash):
+ self._do_test_invalid_hash_value(hash)
+
+ def _do_test_invalid_hash_value(self, 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,
+ )
+
+ # User.set_password() - n/a
+
+ # 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(), [])
+
+ # User.has_usable_password()
+ self.assertEqual(user.has_usable_password(), quirks.invalid_is_usable_password)
+
+ # TODO: is_password_usable()
+
+ # make_password() - n/a
+
+ # check_password()
+ self.assertFalse(check_password(PASS1, hash))
+
+ # identify_hasher() - throws error
+ self.assertRaises(ValueError, identify_hasher, hash)
+
+ 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)
+
+ 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",
@@ -628,15 +844,29 @@ class ExtensionBehaviorTest(DjangoBehaviorTest):
def setUp(self):
super().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"""
@@ -700,12 +930,15 @@ class DjangoExtensionTest(_ExtensionTest):
passlib_to_django = DjangoTranslator().passlib_to_django
# should return native django hasher if available
- if DJANGO_VERSION > (1,10):
+ if DJANGO_VERSION > (1, 10):
self.assertRaises(ValueError, passlib_to_django, "hex_md5")
else:
hasher = passlib_to_django("hex_md5")
self.assertIsInstance(hasher, hashers.UnsaltedMD5PasswordHasher)
+ # should return native django hasher
+ # NOTE: present but not enabled by default in django as of 2.1
+ # (see _builtin_django_hashers)
hasher = passlib_to_django("django_bcrypt")
self.assertIsInstance(hasher, hashers.BCryptPasswordHasher)
@@ -731,6 +964,10 @@ class DjangoExtensionTest(_ExtensionTest):
'hash': u'v2RWkZ*************************************',
})
+ # made up name should throw error
+ # XXX: should this throw ValueError instead, to match django?
+ self.assertRaises(KeyError, passlib_to_django, "does_not_exist")
+
#===================================================================
# PASSLIB_CONFIG settings
#===================================================================
diff --git a/passlib/tests/utils.py b/passlib/tests/utils.py
index d00d30a..9ade9de 100644
--- a/passlib/tests/utils.py
+++ b/passlib/tests/utils.py
@@ -619,6 +619,57 @@ class TestCase(unittest.TestCase):
return value
#===================================================================
+ # subtests
+ #===================================================================
+
+ @contextlib.contextmanager
+ def subTest(self, *args, **kwds):
+ """
+ wrapper for .subTest() which traps SkipTest errors.
+ (see source for details)
+ """
+ # this function works around issue that 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)
+
+ # run the subtest
+ ctx = super().subTest(*args, **kwds)
+ 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)
+ # XXX: should revisit whether latest py3 version of UTs handle this ok,
+ # meaning it's safe to re-raise this.
+ return
+ 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
diff --git a/tox.ini b/tox.ini
index 1a24f21..e148ea6 100644
--- a/tox.ini
+++ b/tox.ini
@@ -25,7 +25,7 @@
#
# "full"
# extra regression and internal tests are enabled, hash algorithms are tested
-# against all available backends, unavailable ones are mocked whre possible,
+# against all available backends, unavailable ones are mocked where possible,
# additional time is devoted to fuzz testing.
#
#-----------------------------------------------------------------------
@@ -73,11 +73,13 @@ envlist =
argon2hash-argon2pure-py{3,py3},
# django tests
- # NOTE: django >= 1.7 distributes tests as part of source, not the package, so for full
+ # NOTE: django distributes it'a tests as part of source, not the package, so for full
# integration tests to run, caller must provide a copy of the latest django source,
# and set the env var PASSLIB_TESTS_DJANGO_SOURCE_PATH to point to it.
- django{18,1x}-wdeps-py3,
- django-{wdeps,nodeps}-py3,
+ # django support roadmap -- https://www.djangoproject.com/download/
+ # django python versions -- https://docs.djangoproject.com/en/3.1/faq/install/#what-python-version-can-i-use-with-django
+ django-dj{Latest,31,30,22,21,20,1x,18}-wdeps-py3,
+ django-dj{Latest}-nodeps-py3,
# other tests
docs
@@ -129,7 +131,7 @@ commands =
argon2hash: nosetests {posargs:{env:TEST_OPTS} passlib.tests.test_handlers_argon2}
# django tests
- django{,1x,18}: nosetests {posargs:{env:TEST_OPTS} passlib.tests.test_ext_django passlib.tests.test_handlers_django}
+ django: nosetests {posargs:{env:TEST_OPTS} passlib.tests.test_ext_django passlib.tests.test_handlers_django}
deps =
# common
@@ -168,14 +170,20 @@ deps =
argon2hash-argon2pure: argon2pure
# django extension tests
- django18: django>=1.8,<1.9
- django1x: django<2
- django: django
- django{,1x,18}-wdeps: bcrypt
- # django{,18}-nodeps -- would like to use this as negative dependancy for 'bcrypt' instead
+ dj18: django<1.9
+ dj1x: django<2.0
+ dj20: django<2.1
+ dj21: django<2.2
+ dj22: django<3.0
+ dj30: django<3.1
+ dj31: django<3.2
+ djLatest: django
+ django-wdeps: bcrypt
+ # django-nodeps -- would like to use this as negative dependancy for 'bcrypt' instead
# needed by django's internal tests
- django{,1x,18}: mock
+ # XXX: does django still need this as of py35?
+ django: mock
#===========================================================================
# build documentation