From 8df8cd54d135380ad8b3b8807a67a3e6915b0b49 Mon Sep 17 00:00:00 2001 From: Andy McCurdy Date: Sun, 29 Sep 2019 22:30:42 -0700 Subject: Added support for ACL commands --- .travis.yml | 2 +- CHANGES | 2 + redis/client.py | 249 +++++++++++++++++++++++++++++++++++++++++- redis/connection.py | 41 ++++--- redis/exceptions.py | 4 + tests/test_commands.py | 176 ++++++++++++++++++++++++++++- tests/test_connection_pool.py | 82 ++++++++++++++ 7 files changed, 536 insertions(+), 20 deletions(-) diff --git a/.travis.yml b/.travis.yml index c28cac4..64351b9 100644 --- a/.travis.yml +++ b/.travis.yml @@ -24,7 +24,7 @@ matrix: - python: 3.8 env: TOXENV=py38-hiredis before_install: - - wget http://download.redis.io/releases/redis-5.0.3.tar.gz && mkdir redis_install && tar -xvzf redis-5.0.3.tar.gz -C redis_install && cd redis_install/redis-5.0.3 && make && src/redis-server --daemonize yes && cd ../.. + - wget https://github.com/antirez/redis/archive/6.0-rc1.tar.gz && mkdir redis_install && tar -xvzf 6.0-rc1.tar.gz -C redis_install && cd redis_install/redis-6.0-rc1 && make && src/redis-server --daemonize yes && cd ../.. - redis-cli info install: - pip install codecov tox diff --git a/CHANGES b/CHANGES index 894b05d..68fb10c 100644 --- a/CHANGES +++ b/CHANGES @@ -4,6 +4,8 @@ without actually running any other commands. Thanks @brianmaissy. #1233, #1234 * Removed support for end of life Python 3.4. + * Added support for all ACL commands in Redis 6. Thanks @IAmATeaPot418 + for helping. * 3.3.11 * Further fix for the SSLError -> TimeoutError mapping to work on obscure releases of Python 2.7. diff --git a/redis/client.py b/redis/client.py index 35d2cfe..c0ac25e 100755 --- a/redis/client.py +++ b/redis/client.py @@ -470,6 +470,30 @@ def parse_client_kill(response, **options): return nativestr(response) == 'OK' +def parse_acl_getuser(response, **options): + if response is None: + return None + data = pairs_to_dict(response, decode_keys=True) + + # convert everything but user-defined data in 'keys' to native strings + data['flags'] = list(map(nativestr, data['flags'])) + data['passwords'] = list(map(nativestr, data['passwords'])) + data['commands'] = nativestr(data['commands']) + + # split 'commands' into separate 'categories' and 'commands' lists + commands, categories = [], [] + for command in data['commands'].split(' '): + if '@' in command: + categories.append(command) + else: + commands.append(command) + + data['commands'] = commands + data['categories'] = categories + data['enabled'] = 'on' in data['flags'] + return data + + class Redis(object): """ Implementation of the Redis protocol. @@ -526,6 +550,16 @@ class Redis(object): string_keys_to_dict('XREAD XREADGROUP', parse_xread), string_keys_to_dict('BGREWRITEAOF BGSAVE', lambda r: True), { + 'ACL CAT': lambda r: list(map(nativestr, r)), + 'ACL DELUSER': int, + 'ACL GENPASS': nativestr, + 'ACL GETUSER': parse_acl_getuser, + 'ACL LIST': lambda r: list(map(nativestr, r)), + 'ACL LOAD': bool_ok, + 'ACL SAVE': bool_ok, + 'ACL SETUSER': bool_ok, + 'ACL USERS': lambda r: list(map(nativestr, r)), + 'ACL WHOAMI': nativestr, 'CLIENT GETNAME': lambda r: r and nativestr(r), 'CLIENT ID': int, 'CLIENT KILL': parse_client_kill, @@ -609,9 +643,9 @@ class Redis(object): For example:: - redis://[:password]@localhost:6379/0 - rediss://[:password]@localhost:6379/0 - unix://[:password]@/path/to/socket.sock?db=0 + redis://[[username]:[password]]@localhost:6379/0 + rediss://[[username]:[password]]@localhost:6379/0 + unix://[[username]:[password]]@/path/to/socket.sock?db=0 Three URL schemes are supported: @@ -640,7 +674,7 @@ class Redis(object): return cls(connection_pool=connection_pool) def __init__(self, host='localhost', port=6379, - db=0, password=None, socket_timeout=None, + db=0, username=None, password=None, socket_timeout=None, socket_connect_timeout=None, socket_keepalive=None, socket_keepalive_options=None, connection_pool=None, unix_socket_path=None, @@ -663,6 +697,7 @@ class Redis(object): kwargs = { 'db': db, + 'username': username, 'password': password, 'socket_timeout': socket_timeout, 'encoding': encoding, @@ -867,6 +902,212 @@ class Redis(object): return response # SERVER INFORMATION + + # ACL methods + def acl_cat(self, category=None): + """ + Returns a list of categories or commands within a category. + + If ``category`` is not supplied, returns a list of all categories. + If ``category`` is supplied, returns a list of all commands within + that category. + """ + pieces = [category] if category else [] + return self.execute_command('ACL CAT', *pieces) + + def acl_deluser(self, username): + "Delete the ACL for the specified ``username``" + return self.execute_command('ACL DELUSER', username) + + def acl_genpass(self): + "Generate a random password value" + return self.execute_command('ACL GENPASS') + + def acl_getuser(self, username): + """ + Get the ACL details for the specified ``username``. + + If ``username`` does not exist, return None + """ + return self.execute_command('ACL GETUSER', username) + + def acl_list(self): + "Return a list of all ACLs on the server" + return self.execute_command('ACL LIST') + + def acl_load(self): + """ + Load ACL rules from the configured ``aclfile``. + + Note that the server must be configured with the ``aclfile`` + directive to be able to load ACL rules from an aclfile. + """ + return self.execute_command('ACL LOAD') + + def acl_save(self): + """ + Save ACL rules to the configured ``aclfile``. + + Note that the server must be configured with the ``aclfile`` + directive to be able to save ACL rules to an aclfile. + """ + return self.execute_command('ACL SAVE') + + def acl_setuser(self, username, enabled=False, nopass=False, + passwords=None, hashed_passwords=None, categories=None, + commands=None, keys=None, reset=False, reset_keys=False, + reset_passwords=False): + """ + Create or update an ACL user. + + Create or update the ACL for ``username``. If the user already exists, + the existing ACL is completely overwritten and replaced with the + specified values. + + ``enabled`` is a boolean indicating whether the user should be allowed + to authenticate or not. Defaults to ``False``. + + ``nopass`` is a boolean indicating whether the can authenticate without + a password. This cannot be True if ``passwords`` are also specified. + + ``passwords`` if specified is a list of plain text passwords + to add to or remove from the user. Each password must be prefixed with + a '+' to add or a '-' to remove. For convenience, the value of + ``add_passwords`` can be a simple prefixed string when adding or + removing a single password. + + ``hashed_passwords`` if specified is a list of SHA-256 hashed passwords + to add to or remove from the user. Each hashed password must be + prefixed with a '+' to add or a '-' to remove. For convenience, + the value of ``hashed_passwords`` can be a simple prefixed string when + adding or removing a single password. + + ``categories`` if specified is a list of strings representing category + permissions. Each string must be prefixed with either a '+' to add the + category permission or a '-' to remove the category permission. + + ``commands`` if specified is a list of strings representing command + permissions. Each string must be prefixed with either a '+' to add the + command permission or a '-' to remove the command permission. + + ``keys`` if specified is a list of key patterns to grant the user + access to. Keys patterns allow '*' to support wildcard matching. For + example, '*' grants access to all keys while 'cache:*' grants access + to all keys that are prefixed with 'cache:'. ``keys`` should not be + prefixed with a '~'. + + ``reset`` is a boolean indicating whether the user should be fully + reset prior to applying the new ACL. Setting this to True will + remove all existing passwords, flags and privileges from the user and + then apply the specified rules. If this is False, the user's existing + passwords, flags and privileges will be kept and any new specified + rules will be applied on top. + + ``reset_keys`` is a boolean indicating whether the user's key + permissions should be reset prior to applying any new key permissions + specified in ``keys``. If this is False, the user's existing + key permissions will be kept and any new specified key permissions + will be applied on top. + + ``reset_passwords`` is a boolean indicating whether to remove all + existing passwords and the 'nopass' flag from the user prior to + applying any new passwords specified in 'passwords' or + 'hashed_passwords'. If this is False, the user's existing passwords + and 'nopass' status will be kept and any new specified passwords + or hashed_passwords will be applied on top. + """ + encoder = self.connection_pool.get_encoder() + pieces = [username] + + if reset: + pieces.append(b'reset') + + if reset_keys: + pieces.append(b'resetkeys') + + if reset_passwords: + pieces.append(b'resetpass') + + if enabled: + pieces.append(b'on') + else: + pieces.append(b'off') + + if (passwords or hashed_passwords) and nopass: + raise DataError('Cannot set \'nopass\' and supply ' + '\'passwords\' or \'hashed_passwords\'') + + if passwords: + # as most users will have only one password, allow remove_passwords + # to be specified as a simple string or a list + passwords = list_or_args(passwords, []) + for i, password in enumerate(passwords): + password = encoder.encode(password) + if password.startswith(b'+'): + pieces.append(b'>%s' % password[1:]) + elif password.startswith(b'-'): + pieces.append(b'<%s' % password[1:]) + else: + raise DataError('Password %d must be prefixeed with a ' + '"+" to add or a "-" to remove' % i) + + if hashed_passwords: + # as most users will have only one password, allow remove_passwords + # to be specified as a simple string or a list + hashed_passwords = list_or_args(hashed_passwords, []) + for i, hashed_password in enumerate(hashed_passwords): + hashed_password = encoder.encode(hashed_password) + if hashed_password.startswith(b'+'): + pieces.append(b'#%s' % hashed_password[1:]) + elif hashed_password.startswith(b'-'): + pieces.append(b'!%s' % hashed_password[1:]) + else: + raise DataError('Hashed %d password must be prefixeed ' + 'with a "+" to add or a "-" to remove' % i) + + if nopass: + pieces.append(b'nopass') + + if categories: + for category in categories: + category = encoder.encode(category) + # categories can be prefixed with one of (+@, +, -@, -) + if category.startswith(b'+@'): + pieces.append(category) + elif category.startswith(b'+'): + pieces.append(b'+@%s' % category[1:]) + elif category.startswith(b'-@'): + pieces.append(category) + elif category.startswith(b'-'): + pieces.append(b'-@%s' % category[1:]) + else: + raise DataError('Category "%s" must be prefixed with ' + '"+" or "-"' + % encoder.decode(category, force=True)) + if commands: + for cmd in commands: + cmd = encoder.encode(cmd) + if not cmd.startswith(b'+') and not cmd.startswith(b'-'): + raise DataError('Command "%s" must be prefixed with ' + '"+" or "-"' + % encoder.decode(cmd, force=True)) + pieces.append(cmd) + + if keys: + for key in keys: + key = encoder.encode(key) + pieces.append(b'~%s' % key) + + return self.execute_command('ACL SETUSER', *pieces) + + def acl_users(self): + "Returns a list of all registered users on the server." + return self.execute_command('ACL USERS') + + def acl_whoami(self): + "Get the username for the current connection" + return self.execute_command('ACL WHOAMI') + def bgrewriteaof(self): "Tell the Redis server to rewrite the AOF file from data in memory." return self.execute_command('BGREWRITEAOF') diff --git a/redis/connection.py b/redis/connection.py index 81b437b..b90cafe 100755 --- a/redis/connection.py +++ b/redis/connection.py @@ -22,6 +22,7 @@ from redis.exceptions import ( DataError, ExecAbortError, InvalidResponse, + NoPermissionError, NoScriptError, ReadOnlyError, RedisError, @@ -139,6 +140,7 @@ class BaseParser(object): 'NOSCRIPT': NoScriptError, 'READONLY': ReadOnlyError, 'NOAUTH': AuthenticationError, + 'NOPERM': NoPermissionError, } def parse_error(self, response): @@ -485,10 +487,11 @@ class Connection(object): "Manages TCP communication to and from a Redis server" description_format = "Connection" - def __init__(self, host='localhost', port=6379, db=0, password=None, - socket_timeout=None, socket_connect_timeout=None, - socket_keepalive=False, socket_keepalive_options=None, - socket_type=0, retry_on_timeout=False, encoding='utf-8', + def __init__(self, host='localhost', port=6379, db=0, username=None, + password=None, socket_timeout=None, + socket_connect_timeout=None, socket_keepalive=False, + socket_keepalive_options=None, socket_type=0, + retry_on_timeout=False, encoding='utf-8', encoding_errors='strict', decode_responses=False, parser_class=DefaultParser, socket_read_size=65536, health_check_interval=0): @@ -496,6 +499,7 @@ class Connection(object): self.host = host self.port = int(port) self.db = db + self.username = username self.password = password self.socket_timeout = socket_timeout self.socket_connect_timeout = socket_connect_timeout or socket_timeout @@ -610,13 +614,17 @@ class Connection(object): "Initialize the connection, authenticate and select a database" self._parser.on_connect(self) - # if a password is specified, authenticate - if self.password: + # if username and/or password are set, authenticate + if self.username or self.password: + if self.username: + auth_args = (self.username, self.password or '') + else: + auth_args = (self.password,) # avoid checking health here -- PING will fail if we try # to check the health prior to the AUTH - self.send_command('AUTH', self.password, check_health=False) + self.send_command('AUTH', *auth_args, check_health=False) if nativestr(self.read_response()) != 'OK': - raise AuthenticationError('Invalid Password') + raise AuthenticationError('Invalid Username or Password') # if a database is specified, switch to it if self.db: @@ -832,7 +840,7 @@ class SSLConnection(Connection): class UnixDomainSocketConnection(Connection): description_format = "UnixDomainSocketConnection" - def __init__(self, path='', db=0, password=None, + def __init__(self, path='', db=0, username=None, password=None, socket_timeout=None, encoding='utf-8', encoding_errors='strict', decode_responses=False, retry_on_timeout=False, @@ -841,6 +849,7 @@ class UnixDomainSocketConnection(Connection): self.pid = os.getpid() self.path = path self.db = db + self.username = username self.password = password self.socket_timeout = socket_timeout self.retry_on_timeout = retry_on_timeout @@ -904,9 +913,9 @@ class ConnectionPool(object): For example:: - redis://[:password]@localhost:6379/0 - rediss://[:password]@localhost:6379/0 - unix://[:password]@/path/to/socket.sock?db=0 + redis://[[username]:[password]]@localhost:6379/0 + rediss://[[username]:[password]]@localhost:6379/0 + unix://[[username]:[password]]@/path/to/socket.sock?db=0 Three URL schemes are supported: @@ -931,7 +940,7 @@ class ConnectionPool(object): percent-encoded URLs. If this argument is set to ``True`` all ``%xx`` escapes will be replaced by their single-character equivalents after the URL has been parsed. This only applies to the ``hostname``, - ``path``, and ``password`` components. + ``path``, ``username`` and ``password`` components. Any additional querystring arguments and keyword arguments will be passed along to the ConnectionPool class's initializer. The querystring @@ -960,17 +969,20 @@ class ConnectionPool(object): url_options[name] = value[0] if decode_components: + username = unquote(url.username) if url.username else None password = unquote(url.password) if url.password else None path = unquote(url.path) if url.path else None hostname = unquote(url.hostname) if url.hostname else None else: - password = url.password + username = url.username or None + password = url.password or None path = url.path hostname = url.hostname # We only support redis://, rediss:// and unix:// schemes. if url.scheme == 'unix': url_options.update({ + 'username': username, 'password': password, 'path': path, 'connection_class': UnixDomainSocketConnection, @@ -980,6 +992,7 @@ class ConnectionPool(object): url_options.update({ 'host': hostname, 'port': int(url.port or 6379), + 'username': username, 'password': password, }) diff --git a/redis/exceptions.py b/redis/exceptions.py index e7f2cbb..9a1852a 100644 --- a/redis/exceptions.py +++ b/redis/exceptions.py @@ -53,6 +53,10 @@ class ReadOnlyError(ResponseError): pass +class NoPermissionError(ResponseError): + pass + + class LockError(RedisError, ValueError): "Errors acquiring or releasing a lock" # NOTE: For backwards compatability, this class derives from ValueError. diff --git a/tests/test_commands.py b/tests/test_commands.py index ef316af..00752fa 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -7,7 +7,7 @@ import redis import time from redis._compat import (unichr, ascii_letters, iteritems, iterkeys, - itervalues, long) + itervalues, long, basestring) from redis.client import parse_info from redis import exceptions @@ -66,6 +66,180 @@ class TestRedisCommands(object): r['a'] # SERVER INFORMATION + @skip_if_server_version_lt('5.9.101') + def test_acl_cat_no_category(self, r): + categories = r.acl_cat() + assert isinstance(categories, list) + assert 'read' in categories + + @skip_if_server_version_lt('5.9.101') + def test_acl_cat_with_category(self, r): + commands = r.acl_cat('read') + assert isinstance(commands, list) + assert 'get' in commands + + @skip_if_server_version_lt('5.9.101') + def test_acl_deluser(self, r, request): + username = 'redis-py-user' + + def teardown(): + r.acl_deluser(username) + + request.addfinalizer(teardown) + + assert r.acl_deluser(username) == 0 + assert r.acl_setuser(username, enabled=False, reset=True) + assert r.acl_deluser(username) == 1 + + @skip_if_server_version_lt('5.9.101') + def test_acl_genpass(self, r): + password = r.acl_genpass() + assert isinstance(password, basestring) + + @skip_if_server_version_lt('5.9.101') + def test_acl_getuser_setuser(self, r, request): + username = 'redis-py-user' + + def teardown(): + r.acl_deluser(username) + request.addfinalizer(teardown) + + # test enabled=False + assert r.acl_setuser(username, enabled=False, reset=True) + assert r.acl_getuser(username) == { + 'categories': ['-@all'], + 'commands': [], + 'enabled': False, + 'flags': ['off'], + 'keys': [], + 'passwords': [], + } + + # test nopass=True + assert r.acl_setuser(username, enabled=True, reset=True, nopass=True) + assert r.acl_getuser(username) == { + 'categories': ['-@all'], + 'commands': [], + 'enabled': True, + 'flags': ['on', 'nopass'], + 'keys': [], + 'passwords': [], + } + + # test all args + assert r.acl_setuser(username, enabled=True, reset=True, + passwords=['+pass1', '+pass2'], + categories=['+set', '+@hash', '-geo'], + commands=['+get', '+mget', '-hset'], + keys=['cache:*', 'objects:*']) + acl = r.acl_getuser(username) + assert set(acl['categories']) == set(['-@all', '+@set', '+@hash']) + assert set(acl['commands']) == set(['+get', '+mget', '-hset']) + assert acl['enabled'] is True + assert acl['flags'] == ['on'] + assert set(acl['keys']) == set([b'cache:*', b'objects:*']) + assert len(acl['passwords']) == 2 + + # test reset=False keeps existing ACL and applies new ACL on top + assert r.acl_setuser(username, enabled=True, reset=True, + passwords=['+pass1'], + categories=['+@set'], + commands=['+get'], + keys=['cache:*']) + assert r.acl_setuser(username, enabled=True, + passwords=['+pass2'], + categories=['+@hash'], + commands=['+mget'], + keys=['objects:*']) + acl = r.acl_getuser(username) + assert set(acl['categories']) == set(['-@all', '+@set', '+@hash']) + assert set(acl['commands']) == set(['+get', '+mget']) + assert acl['enabled'] is True + assert acl['flags'] == ['on'] + assert set(acl['keys']) == set([b'cache:*', b'objects:*']) + assert len(acl['passwords']) == 2 + + # test removal of passwords + assert r.acl_setuser(username, enabled=True, reset=True, + passwords=['+pass1', '+pass2']) + assert len(r.acl_getuser(username)['passwords']) == 2 + assert r.acl_setuser(username, enabled=True, + passwords=['-pass2']) + assert len(r.acl_getuser(username)['passwords']) == 1 + + # Resets and tests that hashed passwords are set properly. + hashed_password = ('5e884898da28047151d0e56f8dc629' + '2773603d0d6aabbdd62a11ef721d1542d8') + assert r.acl_setuser(username, enabled=True, reset=True, + hashed_passwords=['+' + hashed_password]) + acl = r.acl_getuser(username) + assert acl['passwords'] == [hashed_password] + + # test removal of hashed passwords + assert r.acl_setuser(username, enabled=True, reset=True, + hashed_passwords=['+' + hashed_password], + passwords=['+pass1']) + assert len(r.acl_getuser(username)['passwords']) == 2 + assert r.acl_setuser(username, enabled=True, + hashed_passwords=['-' + hashed_password]) + assert len(r.acl_getuser(username)['passwords']) == 1 + + @skip_if_server_version_lt('5.9.101') + def test_acl_list(self, r, request): + username = 'redis-py-user' + + def teardown(): + r.acl_deluser(username) + request.addfinalizer(teardown) + + assert r.acl_setuser(username, enabled=False, reset=True) + users = r.acl_list() + assert 'user %s off -@all' % username in users + + @skip_if_server_version_lt('5.9.101') + def test_acl_setuser_categories_without_prefix_fails(self, r, request): + username = 'redis-py-user' + + def teardown(): + r.acl_deluser(username) + request.addfinalizer(teardown) + + with pytest.raises(exceptions.DataError): + r.acl_setuser(username, categories=['list']) + + @skip_if_server_version_lt('5.9.101') + def test_acl_setuser_commands_without_prefix_fails(self, r, request): + username = 'redis-py-user' + + def teardown(): + r.acl_deluser(username) + request.addfinalizer(teardown) + + with pytest.raises(exceptions.DataError): + r.acl_setuser(username, commands=['get']) + + @skip_if_server_version_lt('5.9.101') + def test_acl_setuser_add_passwords_and_nopass_fails(self, r, request): + username = 'redis-py-user' + + def teardown(): + r.acl_deluser(username) + request.addfinalizer(teardown) + + with pytest.raises(exceptions.DataError): + r.acl_setuser(username, passwords='+mypass', nopass=True) + + @skip_if_server_version_lt('5.9.101') + def test_acl_users(self, r): + users = r.acl_users() + assert isinstance(users, list) + assert len(users) > 0 + + @skip_if_server_version_lt('5.9.101') + def test_acl_whoami(self, r): + username = r.acl_whoami() + assert isinstance(username, basestring) + def test_client_list(self, r): clients = r.client_list() assert isinstance(clients[0], dict) diff --git a/tests/test_connection_pool.py b/tests/test_connection_pool.py index 406b5db..e0f0822 100644 --- a/tests/test_connection_pool.py +++ b/tests/test_connection_pool.py @@ -199,6 +199,7 @@ class TestConnectionPoolURLParsing(object): 'host': 'localhost', 'port': 6379, 'db': 0, + 'username': None, 'password': None, } @@ -209,6 +210,7 @@ class TestConnectionPoolURLParsing(object): 'host': 'myhost', 'port': 6379, 'db': 0, + 'username': None, 'password': None, } @@ -220,6 +222,7 @@ class TestConnectionPoolURLParsing(object): 'host': 'my / host +=+', 'port': 6379, 'db': 0, + 'username': None, 'password': None, } @@ -230,6 +233,33 @@ class TestConnectionPoolURLParsing(object): 'host': 'localhost', 'port': 6380, 'db': 0, + 'username': None, + 'password': None, + } + + @skip_if_server_version_lt('5.9.101') + def test_username(self): + pool = redis.ConnectionPool.from_url('redis://myuser:@localhost') + assert pool.connection_class == redis.Connection + assert pool.connection_kwargs == { + 'host': 'localhost', + 'port': 6379, + 'db': 0, + 'username': 'myuser', + 'password': None, + } + + @skip_if_server_version_lt('5.9.101') + def test_quoted_username(self): + pool = redis.ConnectionPool.from_url( + 'redis://%2Fmyuser%2F%2B name%3D%24+:@localhost', + decode_components=True) + assert pool.connection_class == redis.Connection + assert pool.connection_kwargs == { + 'host': 'localhost', + 'port': 6379, + 'db': 0, + 'username': '/myuser/+ name=$+', 'password': None, } @@ -240,6 +270,7 @@ class TestConnectionPoolURLParsing(object): 'host': 'localhost', 'port': 6379, 'db': 0, + 'username': None, 'password': 'mypassword', } @@ -252,9 +283,22 @@ class TestConnectionPoolURLParsing(object): 'host': 'localhost', 'port': 6379, 'db': 0, + 'username': None, 'password': '/mypass/+ word=$+', } + @skip_if_server_version_lt('5.9.101') + def test_username_and_password(self): + pool = redis.ConnectionPool.from_url('redis://myuser:mypass@localhost') + assert pool.connection_class == redis.Connection + assert pool.connection_kwargs == { + 'host': 'localhost', + 'port': 6379, + 'db': 0, + 'username': 'myuser', + 'password': 'mypass', + } + def test_db_as_argument(self): pool = redis.ConnectionPool.from_url('redis://localhost', db='1') assert pool.connection_class == redis.Connection @@ -262,6 +306,7 @@ class TestConnectionPoolURLParsing(object): 'host': 'localhost', 'port': 6379, 'db': 1, + 'username': None, 'password': None, } @@ -272,6 +317,7 @@ class TestConnectionPoolURLParsing(object): 'host': 'localhost', 'port': 6379, 'db': 2, + 'username': None, 'password': None, } @@ -283,6 +329,7 @@ class TestConnectionPoolURLParsing(object): 'host': 'localhost', 'port': 6379, 'db': 3, + 'username': None, 'password': None, } @@ -300,6 +347,7 @@ class TestConnectionPoolURLParsing(object): 'socket_timeout': 20.0, 'socket_connect_timeout': 10.0, 'retry_on_timeout': True, + 'username': None, 'password': None, } assert pool.max_connections == 10 @@ -339,6 +387,7 @@ class TestConnectionPoolURLParsing(object): 'host': 'localhost', 'port': 6379, 'db': 0, + 'username': None, 'password': None, 'a': '1', 'b': '2' @@ -355,6 +404,7 @@ class TestConnectionPoolURLParsing(object): 'host': 'myhost', 'port': 6379, 'db': 0, + 'username': None, 'password': None, } @@ -370,6 +420,31 @@ class TestConnectionPoolUnixSocketURLParsing(object): assert pool.connection_kwargs == { 'path': '/socket', 'db': 0, + 'username': None, + 'password': None, + } + + @skip_if_server_version_lt('5.9.101') + def test_username(self): + pool = redis.ConnectionPool.from_url('unix://myuser:@/socket') + assert pool.connection_class == redis.UnixDomainSocketConnection + assert pool.connection_kwargs == { + 'path': '/socket', + 'db': 0, + 'username': 'myuser', + 'password': None, + } + + @skip_if_server_version_lt('5.9.101') + def test_quoted_username(self): + pool = redis.ConnectionPool.from_url( + 'unix://%2Fmyuser%2F%2B name%3D%24+:@/socket', + decode_components=True) + assert pool.connection_class == redis.UnixDomainSocketConnection + assert pool.connection_kwargs == { + 'path': '/socket', + 'db': 0, + 'username': '/myuser/+ name=$+', 'password': None, } @@ -379,6 +454,7 @@ class TestConnectionPoolUnixSocketURLParsing(object): assert pool.connection_kwargs == { 'path': '/socket', 'db': 0, + 'username': None, 'password': 'mypassword', } @@ -390,6 +466,7 @@ class TestConnectionPoolUnixSocketURLParsing(object): assert pool.connection_kwargs == { 'path': '/socket', 'db': 0, + 'username': None, 'password': '/mypass/+ word=$+', } @@ -401,6 +478,7 @@ class TestConnectionPoolUnixSocketURLParsing(object): assert pool.connection_kwargs == { 'path': '/my/path/to/../+_+=$ocket', 'db': 0, + 'username': None, 'password': 'mypassword', } @@ -410,6 +488,7 @@ class TestConnectionPoolUnixSocketURLParsing(object): assert pool.connection_kwargs == { 'path': '/socket', 'db': 1, + 'username': None, 'password': None, } @@ -419,6 +498,7 @@ class TestConnectionPoolUnixSocketURLParsing(object): assert pool.connection_kwargs == { 'path': '/socket', 'db': 2, + 'username': None, 'password': None, } @@ -428,6 +508,7 @@ class TestConnectionPoolUnixSocketURLParsing(object): assert pool.connection_kwargs == { 'path': '/socket', 'db': 0, + 'username': None, 'password': None, 'a': '1', 'b': '2' @@ -443,6 +524,7 @@ class TestSSLConnectionURLParsing(object): 'host': 'localhost', 'port': 6379, 'db': 0, + 'username': None, 'password': None, } -- cgit v1.2.1