From b32c2b4848e2d58779a2841ffe4592448ae21899 Mon Sep 17 00:00:00 2001 From: Alexander Schlemmer <alexander.schlemmer@ds.mpg.de> Date: Tue, 23 Oct 2018 01:27:19 +0200 Subject: [PATCH] BUG: 1 Login Error when using password_method=pass --- CHANGELOG.md | 36 ++++++++ pytest.ini | 3 +- setup.cfg | 10 -- setup.py | 4 +- src/caosdb/common/models.py | 8 +- src/caosdb/configuration.py | 22 ++--- .../external_credentials_provider.py | 92 +++++++++++++++++++ .../connection/authentication/interface.py | 57 ++++++++---- .../connection/authentication/keyring.py | 37 +++----- src/caosdb/connection/authentication/pass.py | 36 +++----- src/caosdb/connection/authentication/plain.py | 1 + src/caosdb/connection/connection.py | 36 +++++++- src/caosdb/connection/interface.py | 14 +++ src/caosdb/connection/mockup.py | 3 + src/caosdb/connection/streaminghttp.py | 4 +- src/caosdb/connection/utils.py | 72 +++++++++++++-- src/caosdb/exceptions.py | 31 ++++++- tox.ini | 4 +- unittests/test_authentication_external.py | 61 ++++++++++++ unittests/test_authentication_keyring.py | 41 +++++++++ unittests/test_authentication_pass.py | 43 +++++++++ unittests/test_authentication_plain.py | 61 ++++++++++++ unittests/test_connection.py | 59 +++++++++++- unittests/test_connection_utils.py | 52 +++++++++++ 24 files changed, 673 insertions(+), 114 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 src/caosdb/connection/authentication/external_credentials_provider.py create mode 100644 unittests/test_authentication_external.py create mode 100644 unittests/test_authentication_keyring.py create mode 100644 unittests/test_authentication_pass.py create mode 100644 unittests/test_authentication_plain.py create mode 100644 unittests/test_connection_utils.py diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..0130d91e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,36 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + + + + +## [Unreleased] + +### Added +- An `auth_token` parameter for `caosdb.configure_connection(...)`. This parameter accepts a plain text auth token (which can only be issued by the CaosDB Server). Under the hood, auth tokens are stored plain, instead of urlencoded now. +- New type of exception: `ConfigurationException` for misconfigurations. +- Some unit tests, mainly for the `caosdb.connection.authentication` module + +### Changed +- [pytest](https://docs.pytest.org/en/latest/) is the new preferred unit test frame work. +- If a password is specified in the configuration even though the password_method is not set to `plain`, a warning is logged. +- Under the hood, the password of from a `pass` or `keyring` call is not stored anymore. Instead the password is requested each time a login is necessary. + + +### Deprecated +- Unit test frame work: [Nose](https://nose.readthedocs.io/en/latest/) should not be used anymore. Please use [pytest](https://docs.pytest.org/en/latest/) instead. + +### Fixed +- #1 - Problems with pass as a credentials provider + + + +## [0.1.0] - 2018-10-09 +Tag `v0.1` - Commit 6fc0dcaa + + +### Added +- everything diff --git a/pytest.ini b/pytest.ini index 5ee3d35d..abdd410e 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,2 +1,3 @@ [pytest] -testpaths = unittests \ No newline at end of file +testpaths = unittests +addopts = -vv --cov=caosdb diff --git a/setup.cfg b/setup.cfg index 208d7f78..74c5620b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,12 +1,2 @@ -[nosetests] -verbosity=2 -detailed-errors=1 -with-coverage=1 -cover-package=caosdb -cover-inclusive=1 -cover-branches=1 -cover-erase=1 -#with-doctest=1 - [pycodestyle] ignore=E501,E121,E123,E126,E226,E24,E704,W503,W504 diff --git a/setup.py b/setup.py index b9d76430..f80c6195 100755 --- a/setup.py +++ b/setup.py @@ -1,4 +1,4 @@ -#!/bin/python +#!/usr/bin/env python # -*- encoding: utf-8 -*- # # ** header v3.0 @@ -35,5 +35,5 @@ setup(name='PyCaosDB', install_requires=['lxml>=3.6.4', 'coverage>=4.4.2', 'PyYaml>=3.12', 'future'], extras_require={'keyring': ['keyring>=13.0.0']}, - tests_require=["nose>=1.0"], + tests_require=["pytest"], ) diff --git a/src/caosdb/common/models.py b/src/caosdb/common/models.py index 5052cc1b..e07f9713 100644 --- a/src/caosdb/common/models.py +++ b/src/caosdb/common/models.py @@ -1923,7 +1923,7 @@ class Container(list): inserted/updated/deleted/retrieved at once.""" list.__init__(self) self._timestamp = None - self._session = None + self._srid = None self.messages = _Messages() def extend(self, entities): @@ -2115,7 +2115,7 @@ class Container(list): # ignore pass c._timestamp = xml.get("timestamp") - c._session = xml.get("srid") + c._srid = xml.get("srid") return c else: raise CaosDBException( @@ -2129,6 +2129,8 @@ class Container(list): corresponding entity e2 from C2 via e1._sync(c2). 2) Add any leftover entity from C2 to C1. """ + # TODO: This method is extremely slow. E.g. 30 seconds for 1000 + # entities. sync_dict = self._calc_sync_dict( remote_container=container, @@ -2160,7 +2162,7 @@ class Container(list): self.add_message(m) self._timestamp = container._timestamp - self._session = container._session + self._srid = container._srid def _calc_sync_dict(self, remote_container, unique, raise_exception_on_error, name_case_sensitive): diff --git a/src/caosdb/configuration.py b/src/caosdb/configuration.py index 8b4af56e..9f6b558a 100644 --- a/src/caosdb/configuration.py +++ b/src/caosdb/configuration.py @@ -24,20 +24,20 @@ """Created on 20.09.2016.""" -from sys import hexversion -if hexversion < 0x03000000: - from ConfigParser import ConfigParser # @UnusedImport @UnresolvedImport -else: - from configparser import ConfigParser # @UnresolvedImport @Reimport +try: + # python2 + from ConfigParser import ConfigParser +except ImportError: + # python3 + from configparser import ConfigParser _DEFAULTS = {"Connection": {"url": None, - "timeout": "200", - "username": None, - "password": None, - "password_method": "plain", - "debug": "0", - "cacert": None}, + "timeout": "200", + "username": None, + "password_method": "plain", + "debug": "0", + "cacert": None}, "Container": {"debug": "0"}} diff --git a/src/caosdb/connection/authentication/external_credentials_provider.py b/src/caosdb/connection/authentication/external_credentials_provider.py new file mode 100644 index 00000000..1cf4cefc --- /dev/null +++ b/src/caosdb/connection/authentication/external_credentials_provider.py @@ -0,0 +1,92 @@ +# -*- coding: utf-8 -*- +# +# ** header v3.0 +# This file is a part of the CaosDB Project. +# +# Copyright (C) 2018 Research Group Biomedical Physics, +# Max-Planck-Institute for Dynamics and Self-Organization Göttingen +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <https://www.gnu.org/licenses/>. +# +# ** end header +# +"""external_credentials_provider.""" +from __future__ import absolute_import, unicode_literals +from abc import ABCMeta +import logging +from .plain import PlainTextCredentialsProvider + +# meta class compatible with Python 2 *and* 3: +ABC = ABCMeta(str('ABC'), (object, ), {str('__slots__'): ()}) + + +class ExternalCredentialsProvider(PlainTextCredentialsProvider, ABC): + """ExternalCredentialsProvider. + + Abstract subclass of PlainTextCredentialsProvider which should be used to + implement external credentials provider (e.g. pass, keyring, or any other call + to an external program, which presents the plain text password, which is to be + used for the authentication. + + Parameters + ---------- + callback: Function + A function which has **kwargs argument. This funktion will be called + each time a password is needed with the current connection + configuration as parameters. + """ + + def __init__(self, callback): + super(ExternalCredentialsProvider, self).__init__() + self._callback = callback + self._config = None + + def configure(self, **config): + """configure. + + Parameters + ---------- + **config + Keyword arguments containing the necessary arguments for the + concrete implementation of this class. + + Attributes + ---------- + password : str + The password. This password is not stored in this class. A callback + is called to provide the password each time this property is + called. + + Returns + ------- + None + """ + if "password" in config: + if "password_method" in config: + authm = "`{}`".format(config["password_method"]) + else: + authm = "an external credentials provider" + self.logger.log(logging.WARNING, + ("`password` defined. You configured caosdb to " + "use %s as authentication method and yet " + "provided a password yourself. This indicates " + "a misconfiguration (e.g. in your " + "pycaosdb.ini) and should be avoided."), + authm) + self._config = dict(config) + super(ExternalCredentialsProvider, self).configure(**config) + + @property + def password(self): + return self._callback(**self._config) diff --git a/src/caosdb/connection/authentication/interface.py b/src/caosdb/connection/authentication/interface.py index d3512174..745517a2 100644 --- a/src/caosdb/connection/authentication/interface.py +++ b/src/caosdb/connection/authentication/interface.py @@ -25,13 +25,9 @@ caosdb server.""" from abc import ABCMeta, abstractmethod, abstractproperty import logging -try: - from urllib.parse import quote -except ImportError: - from urllib import quote from caosdb.connection.utils import urlencode from caosdb.connection.interface import CaosDBServerConnection -from caosdb.connection.utils import parse_session_token +from caosdb.connection.utils import parse_auth_token, auth_token_to_cookie from caosdb.exceptions import LoginFailedException # meta class compatible with Python 2 *and* 3: @@ -46,17 +42,29 @@ class AbstractAuthenticator(ABC): Interface for different authentication mechanisms. e.g. username/password authentication or SSH key authentication. + Attributes + ---------- + logger : Logger + A logger which should be used for all logging which has to do with + authentication. + Methods ------- - login - logout - configure + login (abstract) + logout (abstract) + configure (abstract) on_request on_response + + Attributes + ---------- + auth_token : str + A string representation of a CaosDB Auth Token. """ def __init__(self): - self._session_token = None + self.auth_token = None + self.logger = _LOGGER @abstractmethod def login(self): @@ -113,12 +121,12 @@ class AbstractAuthenticator(ABC): Returns ------- """ - self._session_token = parse_session_token( + self.auth_token = parse_auth_token( response.getheader("Set-Cookie")) def on_request(self, method, path, headers, **kwargs): # pylint: disable=unused-argument - """on_request + """on_request. A call-back which is to be called by the connection before each request. This method set the auth cookie for that request. @@ -137,12 +145,12 @@ class AbstractAuthenticator(ABC): Returns ------- """ - if self._session_token is not None: - headers['Cookie'] = self._session_token + if self.auth_token is not None: + headers['Cookie'] = auth_token_to_cookie(self.auth_token) class CredentialsAuthenticator(AbstractAuthenticator): - """CredentialsAuthenticator + """CredentialsAuthenticator. Subclass of AbstractAuthenticator which provides authentication via credentials (username/password). This class always needs a @@ -165,7 +173,7 @@ class CredentialsAuthenticator(AbstractAuthenticator): super(CredentialsAuthenticator, self).__init__() self._credentials_provider = credentials_provider self._connection = None - self._session_token = None + self._auth_token = None def login(self): self._login() @@ -175,9 +183,9 @@ class CredentialsAuthenticator(AbstractAuthenticator): def _logout(self): _LOGGER.debug("[LOGOUT]") - if self._session_token is not None: + if self._auth_token is not None: self._connection.request(method="DELETE", path="logout") - self._session_token = None + self._auth_token = None def _login(self): username = self._credentials_provider.username @@ -220,10 +228,21 @@ class CredentialsProvider(ABC): Attributes ---------- - password - username + password (abstract) + username (abstract) + logger : Logger + A logger which should be used for all logging which has to do with the + provision of credentials. This is usually just the "authentication" + logger. + + Methods + ------- + configure (abstract) """ + def __init__(self): + self.logger = _LOGGER + @abstractmethod def configure(self, **config): """configure. diff --git a/src/caosdb/connection/authentication/keyring.py b/src/caosdb/connection/authentication/keyring.py index da9e7d41..abdbf112 100644 --- a/src/caosdb/connection/authentication/keyring.py +++ b/src/caosdb/connection/authentication/keyring.py @@ -30,7 +30,8 @@ retrieve the password. import sys import imp from getpass import getpass -from .plain import PlainTextCredentialsProvider +from caosdb.exceptions import ConfigurationException +from .external_credentials_provider import ExternalCredentialsProvider from .interface import CredentialsAuthenticator @@ -46,7 +47,7 @@ def get_authentication_provider(): CredentialsAuthenticator with a 'KeyringCaller' as back-end. """ - return CredentialsAuthenticator(KeyringCaller()) + return CredentialsAuthenticator(KeyringCaller(callback=_call_keyring)) def _get_external_keyring(): @@ -64,7 +65,16 @@ def _get_external_keyring(): fil.close() -def _call_keyring(app, username): +def _call_keyring(**config): + if "username" not in config: + raise ConfigurationException("Your configuration did not provide a " + "`username` which is needed by the " + "`KeyringCaller` to retrieve the " + "password in question.") + url = config.get("url") + username = config.get("username") + app = "PyCaosDB — {}".format(url) + password = _call_keyring(app=app, username=username) external_keyring = _get_external_keyring() password = external_keyring.get_password(app, username) if password is None: @@ -76,7 +86,7 @@ def _call_keyring(app, username): return password -class KeyringCaller(PlainTextCredentialsProvider): +class KeyringCaller(ExternalCredentialsProvider): """KeyringCaller. A class for retrieving the password from the external 'gnome keyring' and @@ -91,22 +101,3 @@ class KeyringCaller(PlainTextCredentialsProvider): password username """ - - def configure(self, **config): - """configure. - - Parameters - ---------- - **config - Keyword arguments which contain at least the keywords "username" and - "url". - - Returns - ------- - None - """ - url = config.get("url") - username = config.get("username") - app = "PyCaosDB — {}".format(url) - password = _call_keyring(app=app, username=username) - super(KeyringCaller, self).configure(password=password, **config) diff --git a/src/caosdb/connection/authentication/pass.py b/src/caosdb/connection/authentication/pass.py index 5aa9a81a..9399fc4f 100644 --- a/src/caosdb/connection/authentication/pass.py +++ b/src/caosdb/connection/authentication/pass.py @@ -28,8 +28,9 @@ password. """ from subprocess import check_output, CalledProcessError +from caosdb.exceptions import ConfigurationException from .interface import CredentialsAuthenticator -from .plain import PlainTextCredentialsProvider +from .external_credentials_provider import ExternalCredentialsProvider def get_authentication_provider(): @@ -44,13 +45,19 @@ def get_authentication_provider(): CredentialsAuthenticator with a 'PassCaller' as back-end. """ - return CredentialsAuthenticator(PassCaller()) + return CredentialsAuthenticator(PassCaller(callback=_call_pass)) -def _call_pass(password_identifier): +def _call_pass(**config): + if "password_identifier" not in config: + raise ConfigurationException("Your configuration did not provide a " + "`password_identifier` which is needed " + "by the `PassCaller` to retrieve the " + "password in question.") + try: return check_output( - "pass " + password_identifier, + "pass " + config["password_identifier"], shell=True).splitlines()[0].decode("UTF-8") except CalledProcessError as exc: raise RuntimeError( @@ -59,7 +66,7 @@ def _call_pass(password_identifier): "incorrect or missing.".format(exc.returncode)) -class PassCaller(PlainTextCredentialsProvider): +class PassCaller(ExternalCredentialsProvider): """PassCaller. A class for retrieving the password from the external program 'pass' and @@ -74,20 +81,5 @@ class PassCaller(PlainTextCredentialsProvider): password username """ - - def configure(self, **config): - """configure. - - Parameters - ---------- - **config - Keyword arguments which contain at least the keywords "username" and - "password_identifier". - - Returns - ------- - None - """ - if "password_identifier" in config: - password = _call_pass(config["password_identifier"]) - super(PassCaller, self).configure(password=password, **config) + # all the work is done in _call_pass and the super class + pass diff --git a/src/caosdb/connection/authentication/plain.py b/src/caosdb/connection/authentication/plain.py index 6cb7b1b8..83dd5929 100644 --- a/src/caosdb/connection/authentication/plain.py +++ b/src/caosdb/connection/authentication/plain.py @@ -59,6 +59,7 @@ class PlainTextCredentialsProvider(CredentialsProvider): """ def __init__(self): + super(PlainTextCredentialsProvider, self).__init__() self._password = None self._username = None diff --git a/src/caosdb/connection/connection.py b/src/caosdb/connection/connection.py index faa66297..10674e71 100644 --- a/src/caosdb/connection/connection.py +++ b/src/caosdb/connection/connection.py @@ -37,7 +37,8 @@ import logging from caosdb.exceptions import (CaosDBException, LoginFailedException, AuthorizationException, URITooLongException, ConnectionException, ServerErrorException, - EntityDoesNotExistError, ClientErrorException) + EntityDoesNotExistError, ClientErrorException, + ConfigurationException) from caosdb.configuration import get_config from .utils import urlencode, make_uri_path, parse_url from .interface import CaosDBServerConnection, CaosDBHTTPResponse @@ -66,6 +67,9 @@ class _WrappedHTTPResponse(CaosDBHTTPResponse): def getheaders(self): return self.response.getheaders() + def close(self): + self.response.close() + class _DefaultCaosDBServerConnection(CaosDBServerConnection): def __init__(self): @@ -256,12 +260,34 @@ class _Connection(object): # pylint: disable=useless-object-inheritance cls.__instance = _Connection() return cls.__instance - def configure(self, **kwargs): + def configure(self, **config): self.is_configured = True - self._delegate_connection = kwargs.get("implementation")() - self._delegate_connection.configure(**kwargs) + if "implementation" not in config: + raise ConfigurationException( + "Missing CaosDBServerConnection implementation. You did not " + "specify an `implementation` for the connection.") + try: + self._delegate_connection = config["implementation"]() + if not isinstance(self._delegate_connection, + CaosDBServerConnection): + raise TypeError("The `implementation` callable did not return " + "an instance of CaosDBServerConnection.") + except TypeError as type_err: + raise ConfigurationException( + "Bad CaosDBServerConnection implementation. The " + "implementation must be a callable object which returns an " + "instance of `CaosDBServerConnection` (e.g. a constructor " + "or a factory).", type_err) + self._delegate_connection.configure(**config) + if "password_method" not in config: + raise ConfigurationException("Missing password_method. You did " + "not specify a `password_method` for" + "the connection.") self._authenticator = _get_authenticator( - connection=self._delegate_connection, **kwargs) + connection=self._delegate_connection, **config) + if "auth_token" in config: + self._authenticator.auth_token = config["auth_token"] + return self def retrieve(self, entity_uri_segments=None, query_dict=None, **kwargs): diff --git a/src/caosdb/connection/interface.py b/src/caosdb/connection/interface.py index 1e0c7adb..a6f73917 100644 --- a/src/caosdb/connection/interface.py +++ b/src/caosdb/connection/interface.py @@ -57,6 +57,20 @@ class CaosDBHTTPResponse(ABC): def getheaders(self): """Return all headers.""" + def __enter__(self): + pass + + def __exit__(self, type, value, traceback): + self.close() + + @abstractmethod + def close(self): + """close. + + Close this response. Depending on the implementation this might + also close underlying streams, sockets etc. + """ + class CaosDBServerConnection(ABC): """Abstract class which defines the interface for sending requests to the diff --git a/src/caosdb/connection/mockup.py b/src/caosdb/connection/mockup.py index ed9a25ea..abde5d03 100644 --- a/src/caosdb/connection/mockup.py +++ b/src/caosdb/connection/mockup.py @@ -66,6 +66,9 @@ class MockUpResponse(CaosDBHTTPResponse): def getheaders(self): return self.headers + def close(self): + pass + class MockUpServerConnection(CaosDBServerConnection): """The mock-up connection which does not actually connect to anything but diff --git a/src/caosdb/connection/streaminghttp.py b/src/caosdb/connection/streaminghttp.py index dc8d4fff..fa97a964 100644 --- a/src/caosdb/connection/streaminghttp.py +++ b/src/caosdb/connection/streaminghttp.py @@ -137,7 +137,7 @@ class StreamingHTTPSConnection(client.HTTPSConnection, object): if self.debuglevel > 0: print("sendIng a byte-like") self.sock.sendall(value) - except socket.error: - if socket.error[0] == 32: # Broken pipe + except socket.error as err: + if err.args[0] == 32: # Broken pipe self.close() raise diff --git a/src/caosdb/connection/utils.py b/src/caosdb/connection/utils.py index 57581f90..8c1518c1 100644 --- a/src/caosdb/connection/utils.py +++ b/src/caosdb/connection/utils.py @@ -30,10 +30,11 @@ except ImportError: try: # pragma: no cover # python3 from urllib.parse import (urlencode as _urlencode, quote as _quote, - urlparse, urlunparse) + urlparse, urlunparse, unquote as _unquote) except ImportError: # pragma: no cover # python2 - from urllib import urlencode as _urlencode, quote as _quote + from urllib import (urlencode as _urlencode, quote as _quote, unquote as + _unquote) from urlparse import urlparse, urlunparse import re @@ -164,11 +165,62 @@ def check_python_ssl_version(hexversion): ) -def parse_session_token(cookie): - session_token = None - if cookie is not None: - try: - session_token = re.compile(r";\s*.*$").split(cookie)[0] - except IndexError: - pass - return session_token +_PATTERN = re.compile(r"^SessionToken=([^;]*);.*$") + + +def unquote(string): + """unquote. + + Decode an urlencoded string into a plain text string. + """ + bts = _unquote(string) + if hasattr(bts, "decode"): + # python 2 + return bts.decode("utf-8") + return bts + + +def parse_auth_token(cookie): + """parse_auth_token. + + Parse an auth token from a cookie. + + Parameters + ---------- + cookie : str + A cookie with an urlencoded authtoken. + + Returns + ------- + str + An auth token string. + """ + auth_token = None + if cookie is not None and _PATTERN.match(cookie): + auth_token = unquote(_PATTERN.split(cookie)[1]) + return auth_token + + +def auth_token_to_cookie(auth_token): + """auth_token_to_cookie. + + Urlencode an auth token string and format it as a cookie. + + Parameters + ---------- + auth_token : str + The plain auth token string. + + Raises + ------ + TypeError + If the auth_token was None + + Returns + ------- + str + A cookie + """ + if auth_token is None: + raise TypeError("Parameter `auth_token` was None.") + return "SessionToken=" + quote(auth_token) + ";" diff --git a/src/caosdb/exceptions.py b/src/caosdb/exceptions.py index 581acc2c..a5d6e996 100644 --- a/src/caosdb/exceptions.py +++ b/src/caosdb/exceptions.py @@ -28,11 +28,38 @@ from lxml import etree class CaosDBException(Exception): """Base class of all CaosDB exceptions.""" - def __init__(self, msg=None): - Exception.__init__(self, msg) + def __init__(self, msg=None, *args): + Exception.__init__(self, msg, *args) self.msg = msg +class ConfigurationException(CaosDBException): + """ConfigurationException. + + Indicates a misconfiguration. + + Parameters + ---------- + msg : str + A descriptin of the misconfiguration. The constructor adds + a few lines with explainingg where to find the configuration. + *args + + Attributes + ---------- + msg : str + A description of the misconfiguration. + """ + + def __init__(self, msg, *args): + super(ConfigurationException, self).__init__(msg + + ConfigurationException._INFO, + *args) + + _INFO = ("\n\nPlease check your ~/.pycaosdb.ini and your $PWD/" + ".pycaosdb.ini. Do at least one of them exist and are they correct?") + + class ClientErrorException(CaosDBException): def __init__(self, msg, status, body): self.status = status diff --git a/tox.ini b/tox.ini index 5a7cee25..74d8c643 100644 --- a/tox.ini +++ b/tox.ini @@ -3,4 +3,6 @@ envlist= py27, py34, py35, py36 skip_missing_interpreters = true [testenv] deps=nose -commands= nosetests {posargs} + pytest + pytest-cov +commands=py.test --cov=caosdb -vv {posargs} diff --git a/unittests/test_authentication_external.py b/unittests/test_authentication_external.py new file mode 100644 index 00000000..a8fc6f79 --- /dev/null +++ b/unittests/test_authentication_external.py @@ -0,0 +1,61 @@ +#! -*- coding: utf-8 -*- +# +# ** header v3.0 +# This file is a part of the CaosDB Project. +# +# Copyright (C) 2018 Research Group Biomedical Physics, +# Max-Planck-Institute for Dynamics and Self-Organization Göttingen +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <https://www.gnu.org/licenses/>. +# +# ** end header +# +"""test_authentication_external. + +Tests for the external_credentials_provider modul. +""" + +from __future__ import unicode_literals +import logging +from pytest import raises +from caosdb.connection.authentication import ( + external_credentials_provider as ecp +) + + +class _TestCaller(ecp.ExternalCredentialsProvider): + pass + + +def test_callback(): + def _callback(**config): + assert "opt" in config + return "secret" + t = _TestCaller(callback=_callback) + t.configure(opt=None) + assert t.password == "secret" + + +def test_log_password_incident(): + class _mylogger(logging.Logger): + def log(self, level, msg, *args, **kwargs): + assert level == logging.WARNING + assert "`password` defined." in msg + raise Exception("log") + + t = _TestCaller(callback=None) + t.logger = _mylogger("mylogger") + with raises(Exception) as exc_info: + t.configure(password="password") + assert exc_info.value.args[0] == "log" diff --git a/unittests/test_authentication_keyring.py b/unittests/test_authentication_keyring.py new file mode 100644 index 00000000..71551449 --- /dev/null +++ b/unittests/test_authentication_keyring.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +# +# ** header v3.0 +# This file is a part of the CaosDB Project. +# +# Copyright (C) 2018 Research Group Biomedical Physics, +# Max-Planck-Institute for Dynamics and Self-Organization Göttingen +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <https://www.gnu.org/licenses/>. +# +# ** end header +# +"""test_authentication_keyring. + +Tests for the caosdb.connection.authentication.keyring module. +""" +import sys +from pytest import raises +from caosdb.connection.authentication.keyring import KeyringCaller + + +def test_initialization(): + def _callback(**config): + assert not config + raise Exception("_callback") + k = KeyringCaller(callback=_callback) + k.configure() + with raises(Exception) as exc_info: + assert k.password is None + assert exc_info.value.args[0] == "_callback" diff --git a/unittests/test_authentication_pass.py b/unittests/test_authentication_pass.py new file mode 100644 index 00000000..45bda08a --- /dev/null +++ b/unittests/test_authentication_pass.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +# +# ** header v3.0 +# This file is a part of the CaosDB Project. +# +# Copyright (C) 2018 Research Group Biomedical Physics, +# Max-Planck-Institute for Dynamics and Self-Organization Göttingen +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <https://www.gnu.org/licenses/>. +# +# ** end header +# +"""test_authentication_pass. + +Tests for the caosdb.connection.authentication.pass module. +""" +import sys +from pytest import raises +_PASSCALLER = "caosdb.connection.authentication.pass" +__import__(_PASSCALLER) +PassCaller = sys.modules[_PASSCALLER].PassCaller + + +def test_initialization(): + def _callback(**config): + assert not config + raise Exception("_callback") + p = PassCaller(callback=_callback) + p.configure() + with raises(Exception) as exc_info: + p.password + assert exc_info.value.args[0] == "_callback" diff --git a/unittests/test_authentication_plain.py b/unittests/test_authentication_plain.py new file mode 100644 index 00000000..26c57e95 --- /dev/null +++ b/unittests/test_authentication_plain.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +# +# ** header v3.0 +# This file is a part of the CaosDB Project. +# +# Copyright (C) 2018 Research Group Biomedical Physics, +# Max-Planck-Institute for Dynamics and Self-Organization Göttingen +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <https://www.gnu.org/licenses/>. +# +# ** end header +# +"""test_authentication_plain. + +Unit tests for the modul caosdb.connection.authentication.plain. +""" + +from __future__ import unicode_literals +from pytest import raises +from caosdb.connection.authentication.plain import PlainTextCredentialsProvider + + +def test_subclass_configure(): + """Test the correct passing of the password argument.""" + class SubClassOf(PlainTextCredentialsProvider): + """A simple subclass of PlainTextCredentialsProvider.""" + + def configure(self, **config): + super(SubClassOf, self).configure(password="added in subclass", **config) + + instance = SubClassOf() + instance.configure() + assert instance.password == "added in subclass" + + instance.configure(**{"somearg": "BLA"}) + assert instance.password == "added in subclass" + + instance.configure(somearg="BLA") + assert instance.password == "added in subclass" + + with raises(TypeError) as exc_info: + instance.configure(password="OH NO!") + assert exc_info.value.args[0] == ("configure() got multiple values for " + "keyword argument 'password'") + + +def test_plain_has_logger(): + p = PlainTextCredentialsProvider() + assert hasattr(p, "logger") + assert p.logger.name == "authentication" diff --git a/unittests/test_connection.py b/unittests/test_connection.py index b25b5621..06838d1c 100644 --- a/unittests/test_connection.py +++ b/unittests/test_connection.py @@ -26,9 +26,11 @@ from __future__ import unicode_literals, print_function from builtins import bytes, str # pylint: disable=redefined-builtin import re +from pytest import raises from nose.tools import (assert_equal as eq, assert_raises as raiz, assert_true as tru, assert_is_not_none as there, assert_false as falz) +from caosdb.exceptions import ConfigurationException, LoginFailedException from caosdb.connection.utils import urlencode, make_uri_path, quote from caosdb.connection.connection import ( configure_connection, CaosDBServerConnection, @@ -196,19 +198,70 @@ def test_request_basics(): def setup_two_resources(): def r1(**kwargs): if kwargs["method"] == "GET": - return MockUpResponse(status=123, headers={}, body="response r1") + return MockUpResponse(status=200, headers=kwargs["headers"], body="response r1") def r2(**kwargs): if kwargs["path"] == "matching/path/": return MockUpResponse( status=456, headers={"key": "val"}, body="response r2") + def r3(**kwargs): + if kwargs["path"] == "401": + return MockUpResponse( + status=401, headers={}, body="please login") + connection = test_init_connection() - connection.resources.extend([r1, r2]) + connection.resources.extend([r1, r2, r3]) return connection def test_test_request_with_two_responses(): connection = setup_two_resources() - eq(connection.request(method="GET", path="any").status, 123) + eq(connection.request(method="GET", path="any", headers={}).status, 200) eq(connection.request(method="POST", path="matching/path/").status, 456) + + +def test_missing_implementation(): + connection = configure_connection() + with raises(ConfigurationException) as exc_info: + connection.configure() + assert exc_info.value.args[0].startswith( + "Missing CaosDBServerConnection implementation.") + + +def test_bad_implementation_not_callable(): + connection = configure_connection() + with raises(ConfigurationException) as exc_info: + connection.configure(implementation=None) + assert exc_info.value.args[0].startswith( + "Bad CaosDBServerConnection implementation.") + assert exc_info.value.args[1].args[0] == "'NoneType' object is not callable" + + +def test_bad_implementation_wrong_class(): + connection = configure_connection() + with raises(ConfigurationException) as exc_info: + connection.configure(implementation=dict) + assert exc_info.value.args[0].startswith( + "Bad CaosDBServerConnection implementation.") + assert exc_info.value.args[1].args[0] == ( + "The `implementation` callable did not return an instance of " + "CaosDBServerConnection.") + + +def test_missing_auth_method(): + connection = configure_connection() + with raises(ConfigurationException) as exc_info: + connection.configure(implementation=MockUpServerConnection) + assert exc_info.value.args[0].startswith("Missing password_method.") + + +def test_missing_password(): + connection = configure_connection() + connection.configure(implementation=setup_two_resources, + password_method="plain", auth_token="[test-auth-token]") + assert connection.retrieve(["some"]).headers["Cookie"] == "SessionToken=%5Btest-auth-token%5D;" + connection.configure(implementation=setup_two_resources, + password_method="plain") + with raises(LoginFailedException): + connection.delete(["401"]) diff --git a/unittests/test_connection_utils.py b/unittests/test_connection_utils.py new file mode 100644 index 00000000..c21b453e --- /dev/null +++ b/unittests/test_connection_utils.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +# +# ** header v3.0 +# This file is a part of the CaosDB Project. +# +# Copyright (C) 2018 Research Group Biomedical Physics, +# Max-Planck-Institute for Dynamics and Self-Organization Göttingen +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <https://www.gnu.org/licenses/>. +# +# ** end header +# +"""Test caosdb.connection.utils.""" +# pylint: disable=missing-docstring +from __future__ import unicode_literals, print_function +from pytest import raises +from nose.tools import (assert_equal as eq, assert_raises as raiz, assert_true + as tru, assert_is_not_none as there, assert_false as + falz) +from caosdb.exceptions import ConfigurationException, LoginFailedException +from caosdb.connection.utils import parse_auth_token, auth_token_to_cookie +from caosdb.connection.connection import ( + configure_connection, CaosDBServerConnection, + _DefaultCaosDBServerConnection) +from caosdb.connection.mockup import (MockUpServerConnection, MockUpResponse, + _request_log_message) +from caosdb.configuration import get_config, _reset_config +from caosdb.connection.authentication.interface import CredentialsAuthenticator +from caosdb import execute_query + + +def setup_module(): + _reset_config() + + +def test_parse_auth_token(): + assert parse_auth_token("SessionToken=%5Bblablabla%5D; expires=bla; ...") == "[blablabla]" + + +def test_auth_token_to_cookie(): + assert auth_token_to_cookie("[blablabla]") == "SessionToken=%5Bblablabla%5D;" -- GitLab