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