diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..89e6538abc4ecc13cd139f1d025f991ebe18a894
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,18 @@
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.nox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+*.py,cover
+.hypothesis/
+.pytest_cache/
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index fabf8d89dfb2598badfdb87ea5ed85d123345be6..1596caaa6ccd93c04d3f5af879d081e2f28944ec 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -10,23 +10,72 @@ before_script:
   - python3 -m pylint --version
   - python3 -m coverage --version
 
-linter:
+linter:buster:
+  image: registry.git.cccv.de/uffd/docker-images/buster
   stage: test
   script:
-  - python3 -m pylint --rcfile .pylintrc --output-format=text app.py | tee pylint.txt
+  - pip3 install pylint-gitlab
+  - python3 -m pylint --exit-zero --rcfile .pylintrc --output-format=pylint_gitlab.GitlabCodeClimateReporter app.py > codeclimate.json
+  - python3 -m pylint --exit-zero --rcfile .pylintrc --output-format=pylint_gitlab.GitlabPagesHtmlReporter app.py > pylint.html
+  - python3 -m pylint --rcfile .pylintrc --output-format=text app.py
   artifacts:
-      paths:
-      - pylint.txt
+    when: always
+    paths:
+    - pylint.html
+    reports:
+      codequality: codeclimate.json
 
-#unittest:
-#  stage: test
-#  script:
-#  - python3 -m coverage run runTests.py
-#  - python3 -m coverage report --include "./*"
-#  - python3 -m coverage report -m  --include "./*" > report.txt
-#  - python3 -m coverage html --include "./*"
-#  artifacts:
-#      paths:
-#      - htmlcov/*
-#      - .coverage
-#      - report.txt
+linter:bullseye:
+  image: registry.git.cccv.de/uffd/docker-images/bullseye
+  stage: test
+  script:
+  - pip3 install pylint-gitlab
+  - python3 -m pylint --exit-zero --rcfile .pylintrc --output-format=pylint_gitlab.GitlabCodeClimateReporter app.py > codeclimate.json
+  - python3 -m pylint --exit-zero --rcfile .pylintrc --output-format=pylint_gitlab.GitlabPagesHtmlReporter app.py > pylint.html
+  - python3 -m pylint --rcfile .pylintrc --output-format=text app.py
+  artifacts:
+    when: always
+    paths:
+    - pylint.html
+    reports:
+      codequality: codeclimate.json
+
+unittests:buster:
+  image: registry.git.cccv.de/uffd/docker-images/buster
+  stage: test
+  script:
+  - service slapd start
+  - UNITTEST_OPENLDAP=1 python3-coverage run --include 'app.py' -m pytest --junitxml=report.xml || true
+  - python3-coverage report -m
+  - python3-coverage html
+  - python3-coverage xml
+  artifacts:
+    when: always
+    paths:
+    - htmlcov/index.html
+    - htmlcov
+    expose_as: 'Coverage Report'
+    reports:
+      cobertura: coverage.xml
+      junit: report.xml
+  coverage: '/^TOTAL.*\s+(\d+\%)$/'
+
+unittests:bullseye:
+  image: registry.git.cccv.de/uffd/docker-images/bullseye
+  stage: test
+  script:
+  - service slapd start
+  - UNITTEST_OPENLDAP=1 python3-coverage run --include 'app.py' -m pytest --junitxml=report.xml || true
+  #- python3-coverage report -m
+  - python3-coverage html
+  #- python3-coverage xml
+  artifacts:
+    when: always
+    paths:
+    - htmlcov/index.html
+    - htmlcov
+    expose_as: 'Coverage Report'
+    reports:
+      #cobertura: coverage.xml
+      junit: report.xml
+  #coverage: '/^TOTAL.*\s+(\d+\%)$/'
diff --git a/.pylintrc b/.pylintrc
index 5e758da8d9174f0ddd776d96ecaf9f4530b75d3e..15f00d04eee663eef83ad36a0ca442db52530196 100644
--- a/.pylintrc
+++ b/.pylintrc
@@ -63,90 +63,6 @@ confidence=
 disable=missing-module-docstring,
         missing-class-docstring,
         missing-function-docstring,
-        print-statement,
-        parameter-unpacking,
-        unpacking-in-except,
-        old-raise-syntax,
-        backtick,
-        long-suffix,
-        old-ne-operator,
-        old-octal-literal,
-        import-star-module-level,
-        non-ascii-bytes-literal,
-        raw-checker-failed,
-        bad-inline-option,
-        locally-disabled,
-        file-ignored,
-        suppressed-message,
-        useless-suppression,
-        deprecated-pragma,
-        use-symbolic-message-instead,
-        unused-wildcard-import,
-        apply-builtin,
-        basestring-builtin,
-        buffer-builtin,
-        cmp-builtin,
-        coerce-builtin,
-        execfile-builtin,
-        file-builtin,
-        long-builtin,
-        raw_input-builtin,
-        reduce-builtin,
-        standarderror-builtin,
-        unicode-builtin,
-        xrange-builtin,
-        coerce-method,
-        delslice-method,
-        getslice-method,
-        setslice-method,
-        no-absolute-import,
-        old-division,
-        dict-iter-method,
-        dict-view-method,
-        next-method-called,
-        metaclass-assignment,
-        indexing-exception,
-        raising-string,
-        reload-builtin,
-        oct-method,
-        hex-method,
-        nonzero-method,
-        cmp-method,
-        input-builtin,
-        round-builtin,
-        intern-builtin,
-        unichr-builtin,
-        map-builtin-not-iterating,
-        zip-builtin-not-iterating,
-        range-builtin-not-iterating,
-        filter-builtin-not-iterating,
-        using-cmp-argument,
-        eq-without-hash,
-        div-method,
-        idiv-method,
-        rdiv-method,
-        exception-message-attribute,
-        invalid-str-codec,
-        sys-max-int,
-        bad-python3-import,
-        deprecated-string-function,
-        deprecated-str-translate-call,
-        deprecated-itertools-function,
-        deprecated-types-field,
-        next-method-defined,
-        dict-items-not-iterating,
-        dict-keys-not-iterating,
-        dict-values-not-iterating,
-        deprecated-operator-function,
-        deprecated-urllib-function,
-        xreadlines-attribute,
-        deprecated-sys-function,
-        exception-escape,
-        comprehension-escape,
-        too-few-public-methods,
-        method-hidden,
-        bad-continuation,
-        unused-variable,
 
 # Enable the message, report, category or checker with the given id(s). You can
 # either give multiple identifier separated by comma (,) or put this option
diff --git a/default_config.py b/default_config.py
index 4d3d06163730cda9b6e2838fcd5e344d9fee7e7e..b36dd3a2e4b99f77c890539faffcefe82d393cc2 100644
--- a/default_config.py
+++ b/default_config.py
@@ -1,5 +1,5 @@
-# OAuthProxy will usually served from the same domain as the services that
-# use it for OAuth integration, so make sure that the session cookie does
+# OAuthProxy is usually served from the same domain as the service that
+# uses it for OAuth2 integration. Make sure that the session cookie does
 # not conflict with any other cookies!
 SESSION_COOKIE_NAME = 'oauth-session'
 
diff --git a/test_app.py b/test_app.py
new file mode 100644
index 0000000000000000000000000000000000000000..f72e356f1885ee1fc884fb12ed823b048b41934b
--- /dev/null
+++ b/test_app.py
@@ -0,0 +1,164 @@
+import unittest
+try:
+	import mock
+except ImportError:
+	from unittest import mock
+import json
+import urllib.parse
+
+from flask import session
+from requests import Session, Response
+
+from app import create_app
+
+headers = {
+	'X-CLIENT-ID': 'test_client_id',
+	'X-CLIENT-SECRET': 'test_client_secret',
+	'X-REDIRECT-URI': 'https://127.0.0.123:7654/callback',
+}
+
+class MockRequest:
+	def __init__(self):
+		self.headers = []
+		self.body = ''
+
+class MockResponse:
+	def __init__(self, status_code, json_data=None):
+		self.request = MockRequest()
+		self.ok = status_code == 200
+		self.status_code = status_code
+		self.json = lambda: json_data
+		self.headers = []
+		self.text = json.dumps(json_data)
+
+def mock_request(self, method, url, **kwargs):
+	if method == 'POST' and url == 'https://127.0.0.123:4567/token':
+		return MockResponse(200, {'access_token': '2YotnFZFEjr1zCsicMWpAA',
+		                          'token_type': 'Bearer',
+		                          'expires_in': 3600,
+		                          'refresh_token': 'tGzv3JOkF0XG5Qx2TlKWIA'})
+	if method == 'GET' and url == 'https://127.0.0.123:4567/userinfo':
+		if kwargs['headers']['Authorization'] != 'Bearer 2YotnFZFEjr1zCsicMWpAA':
+			raise Exception()
+		return MockResponse(200, {'id': 1234,
+		                          'name': 'Test User',
+		                          'nickname': 'testuser',
+		                          'email': 'test@example.com',
+		                          'ldap_dn': 'uid=testuser,ou=users,dc=example,dc=com',
+		                          'groups': ['uffd_access', 'users']})
+	print(repr(method), repr(url), repr(kwargs))
+	raise Exception()
+
+@mock.patch.object(Session, 'request', new=mock_request)
+class TestCases(unittest.TestCase):
+	def setUp(self):
+		config = {
+			'TESTING': True,
+			'DEBUG': True,
+			'SECRET_KEY': 'DEBUGKEY',
+			'OAUTH2_AUTH_URL': 'https://127.0.0.123:4567/authorize',
+			'OAUTH2_TOKEN_URL': 'https://127.0.0.123:4567/token',
+			'OAUTH2_USERINFO_URL': 'https://127.0.0.123:4567/userinfo',
+		}
+		self.app = create_app(config)
+		self.client = self.app.test_client()
+		self.client.__enter__()
+		# Just do some request so that we can use url_for
+		self.client.get(path='/')
+
+	def tearDown(self):
+		self.client.__exit__(None, None, None)
+
+	def test_status(self):
+		r = self.client.get(path='/status', headers=headers)
+		self.assertEqual(r.status_code, 200)
+		self.assertIn('test_client_id', r.data.decode())
+		self.assertNotIn('test_client_secret', r.data.decode())
+
+	def test_auth_no_session(self):
+		r = self.client.get(path='/auth', headers=headers)
+		self.assertEqual(r.status_code, 401)
+
+	def test_login(self):
+		r = self.client.get(path='/login', query_string={'url': 'https://127.0.0.123:7654/app'}, headers=headers, follow_redirects=False)
+		self.assertEqual(r.status_code, 302)
+		url = urllib.parse.urlparse(r.location)
+		qs = urllib.parse.parse_qs(url.query)
+		self.assertEqual(url.scheme, 'https')
+		self.assertEqual(url.netloc, '127.0.0.123:4567')
+		self.assertEqual(url.path, '/authorize')
+		self.assertEqual(qs['response_type'], ['code'])
+		self.assertEqual(qs['client_id'], ['test_client_id'])
+		self.assertEqual(qs['redirect_uri'], ['https://127.0.0.123:7654/callback'])
+		self.assertGreater(len(qs['state'][0]), 8)
+		self.assertEqual(session['state'], qs['state'][0])
+		self.assertEqual(session['url'], 'https://127.0.0.123:7654/app')
+
+	def test_callback(self):
+		code = 'testcode'
+		state = 'teststate'
+		with self.client.session_transaction() as session:
+			session['state'] = state
+			session['url'] = 'https://127.0.0.123:7654/app'
+		r = self.client.get(path='/callback', headers=headers, query_string={'code': code, 'state': state}, follow_redirects=False)
+		self.assertEqual(r.status_code, 302)
+		self.assertEqual(r.location, 'https://127.0.0.123:7654/app')
+		with self.client.session_transaction() as session:
+			self.assertEqual(session['user_id'], 1234)
+			self.assertEqual(session['user_name'], 'Test User')
+			self.assertEqual(session['user_nickname'], 'testuser')
+			self.assertEqual(session['user_email'], 'test@example.com')
+			self.assertEqual(session['user_ldap_dn'], 'uid=testuser,ou=users,dc=example,dc=com')
+			self.assertEqual(set(session['user_groups']), set(['uffd_access', 'users']))
+			self.assertNotIn('state', session)
+			self.assertNotIn('url', session)
+
+	def test_auth_session(self):
+		with self.client.session_transaction() as session:
+			session['user_id'] = 1234
+			session['user_name'] = 'Test User'
+			session['user_nickname'] = 'testuser'
+			session['user_email'] = 'test@example.com'
+			session['user_ldap_dn'] = 'uid=testuser,ou=users,dc=example,dc=com'
+			session['user_groups'] = ['uffd_access', 'users']
+		r = self.client.get(path='/auth', headers=headers)
+		self.assertEqual(r.status_code, 200)
+		self.assertEqual(r.headers['OAUTH-USER-ID'], '1234')
+		self.assertEqual(r.headers['OAUTH-USER-NAME'], 'Test User')
+		self.assertEqual(r.headers['OAUTH-USER-NICKNAME'], 'testuser')
+		self.assertEqual(r.headers['OAUTH-USER-EMAIL'], 'test@example.com')
+		self.assertEqual(r.headers['OAUTH-USER-LDAP-DN'], 'uid=testuser,ou=users,dc=example,dc=com')
+		self.assertIn(r.headers['OAUTH-USER-GROUPS'], ['uffd_access,users', 'users,uffd_access'])
+
+	def test_logout(self):
+		with self.client.session_transaction() as session:
+			session['user_id'] = 1234
+			session['user_name'] = 'Test User'
+			session['user_nickname'] = 'testuser'
+			session['user_email'] = 'test@example.com'
+			session['user_ldap_dn'] = 'uid=testuser,ou=users,dc=example,dc=com'
+			session['user_groups'] = ['uffd_access', 'users']
+		r = self.client.get(path='/logout', headers=headers)
+		self.assertEqual(r.status_code, 200)
+		with self.client.session_transaction() as session:
+			self.assertEqual(list(session.keys()), [])
+
+	def test_logout_no_session(self):
+		r = self.client.get(path='/logout', headers=headers)
+		self.assertEqual(r.status_code, 200)
+		with self.client.session_transaction() as session:
+			self.assertEqual(list(session.keys()), [])
+
+	def test_logout_redirect(self):
+		with self.client.session_transaction() as session:
+			session['user_id'] = 1234
+			session['user_name'] = 'Test User'
+			session['user_nickname'] = 'testuser'
+			session['user_email'] = 'test@example.com'
+			session['user_ldap_dn'] = 'uid=testuser,ou=users,dc=example,dc=com'
+			session['user_groups'] = ['uffd_access', 'users']
+		r = self.client.get(path='/logout', headers=headers, query_string={'redirect_url': 'https://127.0.0.123:7654/app/logout'})
+		self.assertEqual(r.status_code, 302)
+		self.assertEqual(r.location, 'https://127.0.0.123:7654/app/logout')
+		with self.client.session_transaction() as session:
+			self.assertEqual(list(session.keys()), [])