From 91ba4a6fc5477cdc9965fe3b076f7bcc5f75453c Mon Sep 17 00:00:00 2001
From: Julian Rother <julian@cccv.de>
Date: Fri, 4 Nov 2022 20:30:36 +0100
Subject: [PATCH] Force charset/collation on MariaDB and enable CI tests

Uffd now requires that MariaDB databases have utf8mb4 charset and
utf8mb4_nopad_bin collation. The collation was chosen for consistency with
SQLite's BINARY collation.
---
 .gitlab-ci.yml         | 28 ++++++++++++++++++++++++++--
 README.md              |  6 ++++--
 check_migrations.py    |  6 +++---
 tests/utils.py         | 11 +++++++++++
 uffd/database.py       | 14 ++++++++++++++
 uffd/migrations/env.py |  9 +++++++++
 6 files changed, 67 insertions(+), 7 deletions(-)

diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index a3cd3bf2..75c6e1ff 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -88,7 +88,7 @@ linter:bullseye:
     reports:
       codequality: codeclimate.json
 
-unittests:buster:
+unittests:buster:sqlite:
   image: registry.git.cccv.de/uffd/docker-images/buster
   stage: test
   needs: []
@@ -111,7 +111,19 @@ unittests:buster:
       junit: report.xml
   coverage: '/^TOTAL.*\s+(\d+\%)$/'
 
-unittests:bullseye:
+unittests:buster:mysql:
+  image: registry.git.cccv.de/uffd/docker-images/buster
+  stage: test
+  needs: []
+  script:
+  - service mysql start
+  - TEST_WITH_MYSQL=1 python3 -m pytest --junitxml=report.xml
+  artifacts:
+    when: always
+    reports:
+      junit: report.xml
+
+unittests:bullseye:sqlite:
   image: registry.git.cccv.de/uffd/docker-images/bullseye
   stage: test
   needs: []
@@ -122,6 +134,18 @@ unittests:bullseye:
     reports:
       junit: report.xml
 
+unittests:bullseye:mysql:
+  image: registry.git.cccv.de/uffd/docker-images/bullseye
+  stage: test
+  needs: []
+  script:
+  - service mariadb start
+  - TEST_WITH_MYSQL=1 python3 -m pytest --junitxml=report.xml
+  artifacts:
+    when: always
+    reports:
+      junit: report.xml
+
 html5validator:
   stage: test
   needs: []
diff --git a/README.md b/README.md
index aecc5429..83505f73 100644
--- a/README.md
+++ b/README.md
@@ -19,7 +19,7 @@ Please note that we refer to Debian packages here and **not** pip packages.
 - python3-flask-babel
 - python3-argon2
 - python3-itsdangerous (also a dependency of python3-flask)
-- python3-mysqldb or python3-pymysql for MySQL/MariaDB support
+- python3-mysqldb or python3-pymysql for MariaDB support
 
 Some of the dependencies (especially fido2) changed their API in recent versions, so make sure to install the versions from Debian Buster or Bullseye.
 For development, you can also use virtualenv with the supplied `requirements.txt`.
@@ -69,10 +69,12 @@ deb https://packages.cccv.de/uffd bullseye main
 Then download [cccv-archive-key.gpg](cccv-archive-key.gpg) and add it to the trusted repository keys in `/etc/apt/trusted.gpg.d/`.
 Afterwards run `apt update && apt install uffd` to install the package.
 
-The Debian package uses uwsgi to run uffd and ships an `uffd-admin` to execute flask commands in the correct context.
+The Debian package uses uwsgi to run uffd and ships an `uffd-admin` script to execute flask commands in the correct context.
 If you upgrade, make sure to run `flask db upgrade` after every update! The Debian package takes care of this by itself using uwsgi pre start hooks.
 For an example uwsgi config, see our [uswgi.ini](uwsgi.ini). You might find our [nginx include file](nginx.include.conf) helpful to setup a web server in front of uwsgi.
 
+Uffd supports SQLite and MariaDB. To use MariaDB, create the database with the options `CHARACTER SET utf8mb4 COLLATE utf8mb4_nopad_bin` and make sure to add the `?charset=utf8mb4` parameter to `SQLALCHEMY_DATABASE_URI`.
+
 ## Python Coding Style Conventions
 
 PEP 8 without double new lines, tabs instead of spaces and a max line length of 160 characters.
diff --git a/check_migrations.py b/check_migrations.py
index eb8d8bc0..4c7d0af0 100755
--- a/check_migrations.py
+++ b/check_migrations.py
@@ -99,12 +99,12 @@ if __name__ == '__main__':
 			conn = MySQLdb.connect(user='root', unix_socket='/var/run/mysqld/mysqld.sock')
 			cur = conn.cursor()
 			try:
-				cur.execute('DROP DATABASE uffd')
+				cur.execute('DROP DATABASE uffd_tests')
 			except:
 				pass
-			cur.execute('CREATE DATABASE uffd')
+			cur.execute('CREATE DATABASE uffd_tests CHARACTER SET utf8mb4 COLLATE utf8mb4_nopad_bin')
 			conn.close()
-			dburi = 'mysql+mysqldb:///uffd?unix_socket=/var/run/mysqld/mysqld.sock'
+			dburi = 'mysql+mysqldb:///uffd_tests?unix_socket=/var/run/mysqld/mysqld.sock&charset=utf8mb4'
 		try:
 			run_test(dburi, rev)
 		except Exception as ex:
diff --git a/tests/utils.py b/tests/utils.py
index 55a6164e..832b203c 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -40,6 +40,17 @@ class AppTestCase(unittest.TestCase):
 			except FileNotFoundError:
 				pass
 			config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:////tmp/uffd-migration-test-db.sqlite3'
+		if os.environ.get('TEST_WITH_MYSQL'):
+			import MySQLdb
+			conn = MySQLdb.connect(user='root', unix_socket='/var/run/mysqld/mysqld.sock')
+			cur = conn.cursor()
+			try:
+				cur.execute('DROP DATABASE uffd_tests')
+			except:
+				pass
+			cur.execute('CREATE DATABASE uffd_tests CHARACTER SET utf8mb4 COLLATE utf8mb4_nopad_bin')
+			conn.close()
+			config['SQLALCHEMY_DATABASE_URI'] = 'mysql+mysqldb:///uffd_tests?unix_socket=/var/run/mysqld/mysqld.sock&charset=utf8mb4'
 		self.app = create_app(config)
 		self.setUpApp()
 
diff --git a/uffd/database.py b/uffd/database.py
index 83d5446d..38ed8951 100644
--- a/uffd/database.py
+++ b/uffd/database.py
@@ -33,6 +33,20 @@ def enable_sqlite_foreign_key_support(dbapi_connection, connection_record):
 def customize_db_engine(engine):
 	if engine.name == 'sqlite':
 		event.listen(engine, 'connect', enable_sqlite_foreign_key_support)
+	elif engine.name in ('mysql', 'mariadb'):
+		@event.listens_for(engine, 'connect')
+		def receive_connect(dbapi_connection, connection_record): # pylint: disable=unused-argument
+			cursor = dbapi_connection.cursor()
+			cursor.execute('SHOW VARIABLES LIKE "character_set_connection"')
+			character_set_connection = cursor.fetchone()[1]
+			if character_set_connection != 'utf8mb4':
+				raise Exception(f'Unsupported connection charset "{character_set_connection}". Make sure to add "?charset=utf8mb4" to SQLALCHEMY_DATABASE_URI!')
+			cursor.execute('SHOW VARIABLES LIKE "collation_database"')
+			collation_database  = cursor.fetchone()[1]
+			if collation_database != 'utf8mb4_nopad_bin':
+				raise Exception(f'Unsupported database collation "{collation_database}". Create the database with "CHARACTER SET utf8mb4 COLLATE utf8mb4_nopad_bin"!')
+			cursor.execute('SET NAMES utf8mb4 COLLATE utf8mb4_nopad_bin')
+			cursor.close()
 
 class SQLAlchemyJSON(JSONEncoder):
 	def default(self, o):
diff --git a/uffd/migrations/env.py b/uffd/migrations/env.py
index 23663ff2..fe8568ac 100755
--- a/uffd/migrations/env.py
+++ b/uffd/migrations/env.py
@@ -3,6 +3,7 @@ from alembic import context
 from sqlalchemy import engine_from_config, pool
 from logging.config import fileConfig
 import logging
+import click
 
 # this is the Alembic Config object, which provides
 # access to the values within the .ini file in use.
@@ -74,6 +75,14 @@ def run_migrations_online():
                       target_metadata=target_metadata,
                       process_revision_directives=process_revision_directives,
                       **current_app.extensions['migrate'].configure_args)
+    if engine.name in ('mysql', 'mariadb'):
+        character_set_connection = connection.execute('SHOW VARIABLES LIKE "character_set_connection"').fetchone()[1]
+        if character_set_connection != 'utf8mb4':
+            raise click.ClickException(f'Unsupported connection charset "{character_set_connection}". Make sure to add "?charset=utf8mb4" to SQLALCHEMY_DATABASE_URI!')
+        collation_database = connection.execute('SHOW VARIABLES LIKE "collation_database"').fetchone()[1]
+        if collation_database != 'utf8mb4_nopad_bin':
+            raise click.ClickException(f'Unsupported database collation "{collation_database}". Create the database with "CHARACTER SET utf8mb4 COLLATE utf8mb4_nopad_bin"!')
+        connection.execute('SET NAMES utf8mb4 COLLATE utf8mb4_nopad_bin')
 
     try:
         with context.begin_transaction():
-- 
GitLab