diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 2fbcc48cd70fbda3297146feabcda91155621927..681a127154d604a227c4c92bd8de5b3826204011 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -120,7 +120,7 @@ html5validator:
   - rm -rf pages
   - mkdir -p pages
   - cp -r uffd/static pages/static
-  - DUMP_PAGES=pages python3 -m unittest discover tests
+  - DUMP_PAGES=pages python3 -m unittest discover tests/views
   - sed -i -e 's/href="\/static\//href=".\/static\//g' -e 's/src="\/static\//src=".\/static\//g' pages/*.html
   - html5validator --root pages 2>&1 | tee html5validator.log
   artifacts:
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/tests/commands/__init__.py b/tests/commands/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/tests/commands/test_role.py b/tests/commands/test_role.py
new file mode 100644
index 0000000000000000000000000000000000000000..9f991eec6aedfcef684faacd3422de42fcc7f8be
--- /dev/null
+++ b/tests/commands/test_role.py
@@ -0,0 +1,148 @@
+from uffd.database import db
+from uffd.models import User, Role, RoleGroup
+
+from tests.utils import UffdTestCase
+
+class TestRoleCLI(UffdTestCase):
+	def setUp(self):
+		super().setUp()
+		role = Role(name='admin')
+		db.session.add(role)
+		role.groups[self.get_admin_group()] = RoleGroup(group=self.get_admin_group())
+		role.members.append(self.get_admin())
+		role = Role(name='base', is_default=True)
+		db.session.add(role)
+		role.groups[self.get_access_group()] = RoleGroup(group=self.get_access_group())
+		db.session.add(Role(name='test'))
+		for user in User.query:
+			user.update_groups()
+		db.session.commit()
+		self.client.__exit__(None, None, None)
+
+	def test_list(self):
+		result = self.app.test_cli_runner().invoke(args=['role', 'list'])
+		self.assertEqual(result.exit_code, 0)
+
+	def test_show(self):
+		result = self.app.test_cli_runner().invoke(args=['role', 'show', 'admin'])
+		self.assertEqual(result.exit_code, 0)
+		result = self.app.test_cli_runner().invoke(args=['role', 'show', 'doesnotexist'])
+		self.assertEqual(result.exit_code, 1)
+
+	def test_create(self):
+		result = self.app.test_cli_runner().invoke(args=['role', 'create', 'test']) # conflicting name
+		self.assertEqual(result.exit_code, 1)
+		result = self.app.test_cli_runner().invoke(args=['role', 'create', 'newrole', '--moderator-group', 'doesnotexist']) # invalid mod group
+		self.assertEqual(result.exit_code, 1)
+		result = self.app.test_cli_runner().invoke(args=['role', 'create', 'newrole', '--add-group', 'doesnotexist']) # invalid group
+		self.assertEqual(result.exit_code, 1)
+		result = self.app.test_cli_runner().invoke(args=['role', 'create', 'newrole', '--add-role', 'doesnotexist']) # invalid role
+		self.assertEqual(result.exit_code, 1)
+		result = self.app.test_cli_runner().invoke(args=['role', 'create', 'newrole', '--description', 'Role description',
+		                                                 '--moderator-group', 'uffd_admin', '--add-group', 'users',
+		                                                 '--add-role', 'admin'])
+		self.assertEqual(result.exit_code, 0)
+		with self.app.test_request_context():
+			role = Role.query.filter_by(name='newrole').one()
+			self.assertIsNotNone(role)
+			self.assertEqual(role.description, 'Role description')
+			self.assertEqual(role.moderator_group, self.get_admin_group())
+			self.assertEqual(list(role.groups), [self.get_users_group()])
+			self.assertEqual(role.included_roles, Role.query.filter_by(name='admin').all())
+		with self.app.test_request_context():
+			for user in User.query:
+				self.assertNotIn(self.get_users_group(), user.groups)
+		result = self.app.test_cli_runner().invoke(args=['role', 'create', 'newbase', '--add-group', 'users', '--default'])
+		self.assertEqual(result.exit_code, 0)
+		with self.app.test_request_context():
+			for user in User.query:
+				self.assertIn(self.get_users_group(), user.groups)
+
+	def test_update(self):
+		result = self.app.test_cli_runner().invoke(args=['role', 'update', 'doesnotexist', '--description', 'New description'])
+		self.assertEqual(result.exit_code, 1)
+		result = self.app.test_cli_runner().invoke(args=['role', 'update', 'test', '--add-group', 'doesnotexist'])
+		self.assertEqual(result.exit_code, 1)
+		result = self.app.test_cli_runner().invoke(args=['role', 'update', 'test', '--remove-group', 'doesnotexist'])
+		self.assertEqual(result.exit_code, 1)
+		result = self.app.test_cli_runner().invoke(args=['role', 'update', 'test', '--add-role', 'doesnotexist'])
+		self.assertEqual(result.exit_code, 1)
+		result = self.app.test_cli_runner().invoke(args=['role', 'update', 'test', '--remove-role', 'doesnotexist'])
+		self.assertEqual(result.exit_code, 1)
+		result = self.app.test_cli_runner().invoke(args=['role', 'update', 'test', '--moderator-group', 'doesnotexist'])
+		self.assertEqual(result.exit_code, 1)
+		result = self.app.test_cli_runner().invoke(args=['role', 'update', 'base', '--description', 'New description',
+		                                                 '--moderator-group', 'uffd_admin', '--add-group', 'users',
+		                                                 '--remove-group', 'uffd_access', '--add-role', 'admin'])
+		self.assertEqual(result.exit_code, 0)
+		with self.app.test_request_context():
+			role = Role.query.filter_by(name='base').first()
+			self.assertIsNotNone(role)
+			self.assertEqual(role.description, 'New description')
+			self.assertEqual(role.moderator_group, self.get_admin_group())
+			self.assertEqual(list(role.groups), [self.get_users_group()])
+			self.assertEqual(role.included_roles, Role.query.filter_by(name='admin').all())
+			self.assertEqual(set(self.get_user().groups), {self.get_users_group(), self.get_admin_group()})
+		result = self.app.test_cli_runner().invoke(args=['role', 'update', 'base', '--no-moderator-group', '--clear-groups',
+		                                                 '--add-group', 'uffd_access', '--remove-role', 'admin',
+		                                                 '--add-role', 'test'])
+		self.assertEqual(result.exit_code, 0)
+		with self.app.test_request_context():
+			role = Role.query.filter_by(name='base').first()
+			self.assertIsNone(role.moderator_group)
+			self.assertEqual(list(role.groups), [self.get_access_group()])
+			self.assertEqual(role.included_roles, Role.query.filter_by(name='test').all())
+			self.assertEqual(set(self.get_user().groups), {self.get_access_group()})
+		result = self.app.test_cli_runner().invoke(args=['role', 'update', 'base', '--clear-roles'])
+		self.assertEqual(result.exit_code, 0)
+		with self.app.test_request_context():
+			role = Role.query.filter_by(name='base').first()
+			self.assertEqual(role.included_roles, [])
+			self.assertEqual(role.is_default, True)
+			self.assertEqual(set(self.get_user().groups), {self.get_access_group()})
+		result = self.app.test_cli_runner().invoke(args=['role', 'update', 'base', '--no-default'])
+		self.assertEqual(result.exit_code, 0)
+		with self.app.test_request_context():
+			role = Role.query.filter_by(name='base').first()
+			self.assertEqual(role.is_default, False)
+			self.assertEqual(set(self.get_user().groups), set())
+		result = self.app.test_cli_runner().invoke(args=['role', 'update', 'base', '--default'])
+		self.assertEqual(result.exit_code, 0)
+		with self.app.test_request_context():
+			role = Role.query.filter_by(name='base').first()
+			self.assertEqual(role.is_default, True)
+			self.assertEqual(set(self.get_user().groups), {self.get_access_group()})
+
+	# Regression test for https://git.cccv.de/uffd/uffd/-/issues/156
+	def test_update_without_description(self):
+		with self.app.test_request_context():
+			role = Role.query.filter_by(name='test').first()
+			role.description = 'Test description'
+			db.session.commit()
+		result = self.app.test_cli_runner().invoke(args=['role', 'update', 'test', '--clear-groups'])
+		self.assertEqual(result.exit_code, 0)
+		with self.app.test_request_context():
+			role = Role.query.filter_by(name='test').first()
+			self.assertEqual(role.description, 'Test description')
+
+	def test_delete(self):
+		with self.app.test_request_context():
+			self.assertIsNotNone(Role.query.filter_by(name='test').first())
+		result = self.app.test_cli_runner().invoke(args=['role', 'delete', 'test'])
+		self.assertEqual(result.exit_code, 0)
+		with self.app.test_request_context():
+			self.assertIsNone(Role.query.filter_by(name='test').first())
+		result = self.app.test_cli_runner().invoke(args=['role', 'delete', 'doesnotexist'])
+		self.assertEqual(result.exit_code, 1)
+		with self.app.test_request_context():
+			self.assertIn(self.get_admin_group(), self.get_admin().groups)
+		result = self.app.test_cli_runner().invoke(args=['role', 'delete', 'admin'])
+		self.assertEqual(result.exit_code, 0)
+		with self.app.test_request_context():
+			self.assertNotIn(self.get_admin_group(), self.get_admin().groups)
+		with self.app.test_request_context():
+			self.assertIn(self.get_access_group(), self.get_user().groups)
+		result = self.app.test_cli_runner().invoke(args=['role', 'delete', 'base'])
+		self.assertEqual(result.exit_code, 0)
+		with self.app.test_request_context():
+			self.assertNotIn(self.get_access_group(), self.get_user().groups)
diff --git a/tests/commands/test_user.py b/tests/commands/test_user.py
new file mode 100644
index 0000000000000000000000000000000000000000..bae05ebfaa09ffb1eeb2b59fdc3f0794fd6b2923
--- /dev/null
+++ b/tests/commands/test_user.py
@@ -0,0 +1,151 @@
+from uffd.database import db
+from uffd.models import User, Group, Role, RoleGroup
+
+from tests.utils import UffdTestCase
+
+class TestUserCLI(UffdTestCase):
+	def setUp(self):
+		super().setUp()
+		role = Role(name='admin')
+		role.groups[self.get_admin_group()] = RoleGroup(group=self.get_admin_group())
+		db.session.add(role)
+		db.session.add(Role(name='test'))
+		db.session.commit()
+		self.client.__exit__(None, None, None)
+
+	def test_list(self):
+		result = self.app.test_cli_runner().invoke(args=['user', 'list'])
+		self.assertEqual(result.exit_code, 0)
+
+	def test_show(self):
+		result = self.app.test_cli_runner().invoke(args=['user', 'show', 'testuser'])
+		self.assertEqual(result.exit_code, 0)
+		result = self.app.test_cli_runner().invoke(args=['user', 'show', 'doesnotexist'])
+		self.assertEqual(result.exit_code, 1)
+
+	def test_create(self):
+		result = self.app.test_cli_runner().invoke(args=['user', 'create', 'new user', '--mail', 'foobar@example.com']) # invalid login name
+		self.assertEqual(result.exit_code, 1)
+		result = self.app.test_cli_runner().invoke(args=['user', 'create', 'newuser', '--mail', '']) # invalid mail
+		self.assertEqual(result.exit_code, 1)
+		result = self.app.test_cli_runner().invoke(args=['user', 'create', 'newuser', '--mail', 'foobar@example.com', '--password', '']) # invalid password
+		self.assertEqual(result.exit_code, 1)
+		result = self.app.test_cli_runner().invoke(args=['user', 'create', 'newuser', '--mail', 'foobar@example.com', '--displayname', '']) # invalid display name
+		self.assertEqual(result.exit_code, 1)
+		result = self.app.test_cli_runner().invoke(args=['user', 'create', 'newuser', '--mail', 'foobar@example.com', '--add-role', 'doesnotexist']) # unknown role
+		self.assertEqual(result.exit_code, 1)
+		result = self.app.test_cli_runner().invoke(args=['user', 'create', 'testuser', '--mail', 'foobar@example.com']) # conflicting name
+		self.assertEqual(result.exit_code, 1)
+		result = self.app.test_cli_runner().invoke(args=['user', 'create', 'newuser', '--mail', 'newmail@example.com',
+		                                                 '--displayname', 'New Display Name', '--password', 'newpassword', '--add-role', 'admin'])
+		self.assertEqual(result.exit_code, 0)
+		with self.app.test_request_context():
+			user = User.query.filter_by(loginname='newuser').first()
+			self.assertIsNotNone(user)
+			self.assertEqual(user.primary_email.address, 'newmail@example.com')
+			self.assertEqual(user.displayname, 'New Display Name')
+			self.assertTrue(user.password.verify('newpassword'))
+			self.assertEqual(user.roles, Role.query.filter_by(name='admin').all())
+			self.assertIn(self.get_admin_group(), user.groups)
+
+	def test_update(self):
+		result = self.app.test_cli_runner().invoke(args=['user', 'update', 'doesnotexist', '--displayname', 'foo'])
+		self.assertEqual(result.exit_code, 1)
+		result = self.app.test_cli_runner().invoke(args=['user', 'update', 'testuser', '--mail', '']) # invalid mail
+		self.assertEqual(result.exit_code, 1)
+		result = self.app.test_cli_runner().invoke(args=['user', 'update', 'testuser', '--password', '']) # invalid password
+		self.assertEqual(result.exit_code, 1)
+		result = self.app.test_cli_runner().invoke(args=['user', 'update', 'testuser', '--displayname', '']) # invalid display name
+		self.assertEqual(result.exit_code, 1)
+		result = self.app.test_cli_runner().invoke(args=['user', 'update', 'testuser', '--remove-role', 'doesnotexist'])
+		self.assertEqual(result.exit_code, 1)
+		result = self.app.test_cli_runner().invoke(args=['user', 'update', 'testuser', '--mail', 'newmail@example.com',
+		                                                 '--displayname', 'New Display Name', '--password', 'newpassword'])
+		self.assertEqual(result.exit_code, 0)
+		with self.app.test_request_context():
+			user = User.query.filter_by(loginname='testuser').first()
+			self.assertIsNotNone(user)
+			self.assertEqual(user.primary_email.address, 'newmail@example.com')
+			self.assertEqual(user.displayname, 'New Display Name')
+			self.assertTrue(user.password.verify('newpassword'))
+		result = self.app.test_cli_runner().invoke(args=['user', 'update', 'testuser', '--add-role', 'admin', '--add-role', 'test'])
+		self.assertEqual(result.exit_code, 0)
+		with self.app.test_request_context():
+			user = User.query.filter_by(loginname='testuser').first()
+			self.assertEqual(set(user.roles), {Role.query.filter_by(name='admin').one(), Role.query.filter_by(name='test').one()})
+			self.assertIn(self.get_admin_group(), user.groups)
+		result = self.app.test_cli_runner().invoke(args=['user', 'update', 'testuser', '--remove-role', 'admin'])
+		self.assertEqual(result.exit_code, 0)
+		with self.app.test_request_context():
+			user = User.query.filter_by(loginname='testuser').first()
+			self.assertEqual(user.roles, Role.query.filter_by(name='test').all())
+			self.assertNotIn(self.get_admin_group(), user.groups)
+		result = self.app.test_cli_runner().invoke(args=['user', 'update', 'testuser', '--clear-roles', '--add-role', 'admin'])
+		self.assertEqual(result.exit_code, 0)
+		with self.app.test_request_context():
+			user = User.query.filter_by(loginname='testuser').first()
+			self.assertEqual(user.roles, Role.query.filter_by(name='admin').all())
+			self.assertIn(self.get_admin_group(), user.groups)
+
+	def test_delete(self):
+		with self.app.test_request_context():
+			self.assertIsNotNone(User.query.filter_by(loginname='testuser').first())
+		result = self.app.test_cli_runner().invoke(args=['user', 'delete', 'testuser'])
+		self.assertEqual(result.exit_code, 0)
+		with self.app.test_request_context():
+			self.assertIsNone(User.query.filter_by(loginname='testuser').first())
+		result = self.app.test_cli_runner().invoke(args=['user', 'delete', 'doesnotexist'])
+		self.assertEqual(result.exit_code, 1)
+
+class TestGroupCLI(UffdTestCase):
+	def setUp(self):
+		super().setUp()
+		self.client.__exit__(None, None, None)
+
+	def test_list(self):
+		result = self.app.test_cli_runner().invoke(args=['group', 'list'])
+		self.assertEqual(result.exit_code, 0)
+
+	def test_show(self):
+		result = self.app.test_cli_runner().invoke(args=['group', 'show', 'users'])
+		self.assertEqual(result.exit_code, 0)
+		result = self.app.test_cli_runner().invoke(args=['group', 'show', 'doesnotexist'])
+		self.assertEqual(result.exit_code, 1)
+
+	def test_create(self):
+		result = self.app.test_cli_runner().invoke(args=['group', 'create', 'users']) # Duplicate name
+		self.assertEqual(result.exit_code, 1)
+		result = self.app.test_cli_runner().invoke(args=['group', 'create', 'new group'])
+		self.assertEqual(result.exit_code, 1)
+		result = self.app.test_cli_runner().invoke(args=['group', 'create', 'newgroup', '--description', 'A new group'])
+		self.assertEqual(result.exit_code, 0)
+		with self.app.test_request_context():
+			group = Group.query.filter_by(name='newgroup').first()
+			self.assertIsNotNone(group)
+			self.assertEqual(group.description, 'A new group')
+
+	def test_update(self):
+		result = self.app.test_cli_runner().invoke(args=['group', 'update', 'doesnotexist', '--description', 'foo'])
+		self.assertEqual(result.exit_code, 1)
+		result = self.app.test_cli_runner().invoke(args=['group', 'update', 'users', '--description', 'New description'])
+		self.assertEqual(result.exit_code, 0)
+		with self.app.test_request_context():
+			group = Group.query.filter_by(name='users').first()
+			self.assertEqual(group.description, 'New description')
+
+	def test_update_without_description(self):
+		result = self.app.test_cli_runner().invoke(args=['group', 'update', 'users']) # Should not change anything
+		self.assertEqual(result.exit_code, 0)
+		with self.app.test_request_context():
+			group = Group.query.filter_by(name='users').first()
+			self.assertEqual(group.description, 'Base group for all users')
+
+	def test_delete(self):
+		with self.app.test_request_context():
+			self.assertIsNotNone(Group.query.filter_by(name='users').first())
+		result = self.app.test_cli_runner().invoke(args=['group', 'delete', 'users'])
+		self.assertEqual(result.exit_code, 0)
+		with self.app.test_request_context():
+			self.assertIsNone(Group.query.filter_by(name='users').first())
+		result = self.app.test_cli_runner().invoke(args=['group', 'delete', 'doesnotexist'])
+		self.assertEqual(result.exit_code, 1)
diff --git a/tests/models/__init__.py b/tests/models/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/tests/models/test_invite.py b/tests/models/test_invite.py
new file mode 100644
index 0000000000000000000000000000000000000000..fbbf8ab7896fc41e0b54f7825346a3657053a9fe
--- /dev/null
+++ b/tests/models/test_invite.py
@@ -0,0 +1,266 @@
+import datetime
+
+from flask import current_app
+
+from uffd.database import db
+from uffd.models import Invite, InviteGrant, InviteSignup, User, Role, RoleGroup
+
+from tests.utils import UffdTestCase, db_flush
+
+class TestInviteModel(UffdTestCase):
+	def test_expire(self):
+		invite = Invite(valid_until=datetime.datetime.utcnow() + datetime.timedelta(seconds=60), creator=self.get_admin())
+		self.assertFalse(invite.expired)
+		self.assertTrue(invite.active)
+		invite.valid_until = datetime.datetime.utcnow() - datetime.timedelta(seconds=60)
+		self.assertTrue(invite.expired)
+		self.assertFalse(invite.active)
+
+	def test_void(self):
+		invite = Invite(valid_until=datetime.datetime.utcnow() + datetime.timedelta(seconds=60), single_use=False, creator=self.get_admin())
+		self.assertFalse(invite.voided)
+		self.assertTrue(invite.active)
+		invite.used = True
+		self.assertFalse(invite.voided)
+		self.assertTrue(invite.active)
+		invite = Invite(valid_until=datetime.datetime.utcnow() + datetime.timedelta(seconds=60), single_use=True, creator=self.get_admin())
+		self.assertFalse(invite.voided)
+		self.assertTrue(invite.active)
+		invite.used = True
+		self.assertTrue(invite.voided)
+		self.assertFalse(invite.active)
+
+	def test_permitted(self):
+		role = Role(name='testrole')
+		invite = Invite(valid_until=datetime.datetime.utcnow() + datetime.timedelta(seconds=60), allow_signup=True, roles=[role])
+		self.assertFalse(invite.permitted)
+		self.assertFalse(invite.active)
+		invite.creator = self.get_admin()
+		self.assertTrue(invite.permitted)
+		self.assertTrue(invite.active)
+		invite.creator = self.get_user()
+		self.assertFalse(invite.permitted)
+		self.assertFalse(invite.active)
+		role.moderator_group = self.get_access_group()
+		current_app.config['ACL_SIGNUP_GROUP'] = 'uffd_access'
+		self.assertTrue(invite.permitted)
+		self.assertTrue(invite.active)
+		role.moderator_group = None
+		self.assertFalse(invite.permitted)
+		self.assertFalse(invite.active)
+		role.moderator_group = self.get_access_group()
+		current_app.config['ACL_SIGNUP_GROUP'] = 'uffd_admin'
+		self.assertFalse(invite.permitted)
+		self.assertFalse(invite.active)
+
+	def test_disable(self):
+		invite = Invite(valid_until=datetime.datetime.utcnow() + datetime.timedelta(seconds=60), creator=self.get_admin())
+		self.assertTrue(invite.active)
+		invite.disable()
+		self.assertFalse(invite.active)
+
+	def test_reset_disabled(self):
+		invite = Invite(valid_until=datetime.datetime.utcnow() + datetime.timedelta(seconds=60), creator=self.get_admin())
+		invite.disable()
+		self.assertFalse(invite.active)
+		invite.reset()
+		self.assertTrue(invite.active)
+
+	def test_reset_expired(self):
+		invite = Invite(valid_until=datetime.datetime.utcnow() - datetime.timedelta(seconds=60), creator=self.get_admin())
+		self.assertFalse(invite.active)
+		invite.reset()
+		self.assertFalse(invite.active)
+
+	def test_reset_single_use(self):
+		invite = Invite(valid_until=datetime.datetime.utcnow() + datetime.timedelta(seconds=60), single_use=False, creator=self.get_admin())
+		invite.used = True
+		invite.disable()
+		self.assertFalse(invite.active)
+		invite.reset()
+		self.assertTrue(invite.active)
+
+	def test_short_token(self):
+		invite = Invite(valid_until=datetime.datetime.utcnow() + datetime.timedelta(seconds=60), creator=self.get_admin())
+		db.session.add(invite)
+		db.session.commit()
+		self.assertTrue(len(invite.short_token) <= len(invite.token)/3)
+
+class TestInviteGrantModel(UffdTestCase):
+	def test_success(self):
+		user = self.get_user()
+		group0 = self.get_access_group()
+		role0 = Role(name='baserole', groups={group0: RoleGroup(group=group0)})
+		db.session.add(role0)
+		user.roles.append(role0)
+		user.update_groups()
+		group1 = self.get_admin_group()
+		role1 = Role(name='testrole1', groups={group1: RoleGroup(group=group1)})
+		db.session.add(role1)
+		role2 = Role(name='testrole2')
+		db.session.add(role2)
+		invite = Invite(valid_until=datetime.datetime.utcnow() + datetime.timedelta(seconds=60), roles=[role1, role2], creator=self.get_admin())
+		self.assertIn(role0, user.roles)
+		self.assertNotIn(role1, user.roles)
+		self.assertNotIn(role2, user.roles)
+		self.assertIn(group0, user.groups)
+		self.assertNotIn(group1, user.groups)
+		self.assertFalse(invite.used)
+		grant = InviteGrant(invite=invite, user=user)
+		success, msg = grant.apply()
+		self.assertTrue(success)
+		self.assertIn(role0, user.roles)
+		self.assertIn(role1, user.roles)
+		self.assertIn(role2, user.roles)
+		self.assertIn(group0, user.groups)
+		self.assertIn(group1, user.groups)
+		self.assertTrue(invite.used)
+		db.session.commit()
+		db_flush()
+		user = self.get_user()
+		self.assertIn('baserole', [role.name for role in user.roles_effective])
+		self.assertIn('testrole1', [role.name for role in user.roles])
+		self.assertIn('testrole2', [role.name for role in user.roles])
+		self.assertIn(self.get_access_group(), user.groups)
+		self.assertIn(self.get_admin_group(), user.groups)
+
+	def test_inactive(self):
+		user = self.get_user()
+		group = self.get_admin_group()
+		role = Role(name='testrole1', groups={group: RoleGroup(group=group)})
+		db.session.add(role)
+		invite = Invite(valid_until=datetime.datetime.utcnow() + datetime.timedelta(seconds=60), roles=[role], single_use=True, used=True, creator=self.get_admin())
+		self.assertFalse(invite.active)
+		grant = InviteGrant(invite=invite, user=user)
+		success, msg = grant.apply()
+		self.assertFalse(success)
+		self.assertIsInstance(msg, str)
+		self.assertNotIn(role, user.roles)
+		self.assertNotIn(group, user.groups)
+
+	def test_no_roles(self):
+		user = self.get_user()
+		invite = Invite(valid_until=datetime.datetime.utcnow() + datetime.timedelta(seconds=60), creator=self.get_admin())
+		self.assertTrue(invite.active)
+		grant = InviteGrant(invite=invite, user=user)
+		success, msg = grant.apply()
+		self.assertFalse(success)
+		self.assertIsInstance(msg, str)
+
+	def test_no_new_roles(self):
+		user = self.get_user()
+		role = Role(name='testrole1')
+		db.session.add(role)
+		user.roles.append(role)
+		invite = Invite(valid_until=datetime.datetime.utcnow() + datetime.timedelta(seconds=60), roles=[role], creator=self.get_admin())
+		self.assertTrue(invite.active)
+		grant = InviteGrant(invite=invite, user=user)
+		success, msg = grant.apply()
+		self.assertFalse(success)
+		self.assertIsInstance(msg, str)
+
+class TestInviteSignupModel(UffdTestCase):
+	def create_base_roles(self):
+		baserole = Role(name='base', is_default=True)
+		baserole.groups[self.get_access_group()] = RoleGroup()
+		baserole.groups[self.get_users_group()] = RoleGroup()
+		db.session.add(baserole)
+		db.session.commit()
+
+	def test_success(self):
+		self.create_base_roles()
+		base_role = Role.query.filter_by(name='base').one()
+		base_group1 = self.get_access_group()
+		base_group2 = self.get_users_group()
+		group = self.get_admin_group()
+		role1 = Role(name='testrole1', groups={group: RoleGroup(group=group)})
+		db.session.add(role1)
+		role2 = Role(name='testrole2')
+		db.session.add(role2)
+		invite = Invite(valid_until=datetime.datetime.utcnow() + datetime.timedelta(seconds=60), roles=[role1, role2], allow_signup=True, creator=self.get_admin())
+		signup = InviteSignup(invite=invite, loginname='newuser', displayname='New User', mail='test@example.com', password='notsecret')
+		self.assertFalse(invite.used)
+		valid, msg = signup.validate()
+		self.assertTrue(valid)
+		self.assertFalse(invite.used)
+		user, msg = signup.finish('notsecret')
+		self.assertIsInstance(user, User)
+		self.assertTrue(invite.used)
+		self.assertEqual(user.loginname, 'newuser')
+		self.assertEqual(user.displayname, 'New User')
+		self.assertEqual(user.primary_email.address, 'test@example.com')
+		self.assertEqual(signup.user, user)
+		self.assertIn(base_role, user.roles_effective)
+		self.assertIn(role1, user.roles)
+		self.assertIn(role2, user.roles)
+		self.assertIn(base_group1, user.groups)
+		self.assertIn(base_group2, user.groups)
+		self.assertIn(group, user.groups)
+		db.session.commit()
+		db_flush()
+		self.assertEqual(len(User.query.filter_by(loginname='newuser').all()), 1)
+
+	def test_success_no_roles(self):
+		self.create_base_roles()
+		base_role = Role.query.filter_by(name='base').one()
+		base_group1 = self.get_access_group()
+		base_group2 = self.get_users_group()
+		invite = Invite(valid_until=datetime.datetime.utcnow() + datetime.timedelta(seconds=60), allow_signup=True, creator=self.get_admin())
+		signup = InviteSignup(invite=invite, loginname='newuser', displayname='New User', mail='test@example.com', password='notsecret')
+		self.assertFalse(invite.used)
+		valid, msg = signup.validate()
+		self.assertTrue(valid)
+		self.assertFalse(invite.used)
+		user, msg = signup.finish('notsecret')
+		self.assertIsInstance(user, User)
+		self.assertTrue(invite.used)
+		self.assertEqual(user.loginname, 'newuser')
+		self.assertEqual(user.displayname, 'New User')
+		self.assertEqual(user.primary_email.address, 'test@example.com')
+		self.assertEqual(signup.user, user)
+		self.assertIn(base_role, user.roles_effective)
+		self.assertEqual(len(user.roles_effective), 1)
+		self.assertIn(base_group1, user.groups)
+		self.assertIn(base_group2, user.groups)
+		self.assertEqual(len(user.groups), 2)
+		db.session.commit()
+		db_flush()
+		self.assertEqual(len(User.query.filter_by(loginname='newuser').all()), 1)
+
+	def test_inactive(self):
+		invite = Invite(valid_until=datetime.datetime.utcnow() + datetime.timedelta(seconds=60), allow_signup=True, single_use=True, used=True, creator=self.get_admin())
+		self.assertFalse(invite.active)
+		signup = InviteSignup(invite=invite, loginname='newuser', displayname='New User', mail='test@example.com', password='notsecret')
+		valid, msg = signup.validate()
+		self.assertFalse(valid)
+		self.assertIsInstance(msg, str)
+		user, msg = signup.finish('notsecret')
+		self.assertIsNone(user)
+		self.assertIsInstance(msg, str)
+
+	def test_invalid(self):
+		invite = Invite(valid_until=datetime.datetime.utcnow() + datetime.timedelta(seconds=60), allow_signup=True, creator=self.get_admin())
+		self.assertTrue(invite.active)
+		signup = InviteSignup(invite=invite, loginname='', displayname='New User', mail='test@example.com', password='notsecret')
+		valid, msg = signup.validate()
+		self.assertFalse(valid)
+		self.assertIsInstance(msg, str)
+
+	def test_invalid2(self):
+		invite = Invite(valid_until=datetime.datetime.utcnow() + datetime.timedelta(seconds=60), allow_signup=True, creator=self.get_admin())
+		self.assertTrue(invite.active)
+		signup = InviteSignup(invite=invite, loginname='newuser', displayname='New User', mail='test@example.com', password='notsecret')
+		user, msg = signup.finish('wrongpassword')
+		self.assertIsNone(user)
+		self.assertIsInstance(msg, str)
+
+	def test_no_signup(self):
+		invite = Invite(valid_until=datetime.datetime.utcnow() + datetime.timedelta(seconds=60), allow_signup=False, creator=self.get_admin())
+		self.assertTrue(invite.active)
+		signup = InviteSignup(invite=invite, loginname='newuser', displayname='New User', mail='test@example.com', password='notsecret')
+		valid, msg = signup.validate()
+		self.assertFalse(valid)
+		self.assertIsInstance(msg, str)
+		user, msg = signup.finish('notsecret')
+		self.assertIsNone(user)
+		self.assertIsInstance(msg, str)
diff --git a/tests/models/test_mfa.py b/tests/models/test_mfa.py
new file mode 100644
index 0000000000000000000000000000000000000000..f2494fafc124119bd1d73b733f707036ca8144d7
--- /dev/null
+++ b/tests/models/test_mfa.py
@@ -0,0 +1,99 @@
+import unittest
+import datetime
+import time
+
+from uffd.database import db
+from uffd.models import RecoveryCodeMethod, TOTPMethod, WebauthnMethod
+from uffd.models.mfa import _hotp
+
+from tests.utils import UffdTestCase
+
+class TestMfaPrimitives(unittest.TestCase):
+	def test_hotp(self):
+		self.assertEqual(_hotp(5555555, b'\xae\xa3T\x05\x89\xd6\xb76\xf61r\x92\xcc\xb5WZ\xe6)\x05q'), '458290')
+		self.assertEqual(_hotp(5555555, b'\xae\xa3T\x05\x89\xd6\xb76\xf61r\x92\xcc\xb5WZ\xe6)\x05q', digits=8), '20458290')
+		for digits in range(1, 10):
+			self.assertEqual(len(_hotp(1, b'abcd', digits=digits)), digits)
+		self.assertEqual(_hotp(1234, b''), '161024')
+		self.assertEqual(_hotp(0, b'\x04\x8fM\xcc\x7f\x82\x9c$a\x1b\xb3'), '279354')
+		self.assertEqual(_hotp(2**64-1, b'abcde'), '899292')
+
+def get_fido2_test_cred(self):
+	try:
+		from uffd.fido2_compat import AttestedCredentialData
+	except ImportError:
+		self.skipTest('fido2 could not be imported')
+	# Example public key from webauthn spec 6.5.1.1
+	return AttestedCredentialData(bytes.fromhex('00000000000000000000000000000000'+'0040'+'053cbcc9d37a61d3bac87cdcc77ee326256def08ab15775d3a720332e4101d14fae95aeee3bc9698781812e143c0597dc6e180595683d501891e9dd030454c0a'+'A501020326200121582065eda5a12577c2bae829437fe338701a10aaa375e1bb5b5de108de439c08551d2258201e52ed75701163f7f9e40ddf9f341b3dc9ba860af7e0ca7ca7e9eecd0084d19c'))
+
+class TestMfaMethodModels(UffdTestCase):
+	def test_common_attributes(self):
+		method = TOTPMethod(user=self.get_user(), name='testname')
+		self.assertTrue(method.created <= datetime.datetime.utcnow())
+		self.assertEqual(method.name, 'testname')
+		self.assertEqual(method.user.loginname, 'testuser')
+		method.user = self.get_admin()
+		self.assertEqual(method.user.loginname, 'testadmin')
+
+	def test_recovery_code_method(self):
+		method = RecoveryCodeMethod(user=self.get_user())
+		db.session.add(method)
+		db.session.commit()
+		method_id = method.id
+		method_code = method.code
+		db.session.expunge(method)
+		method = RecoveryCodeMethod.query.get(method_id)
+		self.assertFalse(hasattr(method, 'code'))
+		self.assertFalse(method.verify(''))
+		self.assertFalse(method.verify('A'*8))
+		self.assertTrue(method.verify(method_code))
+
+	def test_totp_method_attributes(self):
+		method = TOTPMethod(user=self.get_user(), name='testname')
+		raw_key = method.raw_key
+		issuer = method.issuer
+		accountname = method.accountname
+		key_uri = method.key_uri
+		self.assertEqual(method.name, 'testname')
+		# Restore method with key parameter
+		_method = TOTPMethod(user=self.get_user(), key=method.key, name='testname')
+		self.assertEqual(_method.name, 'testname')
+		self.assertEqual(_method.raw_key, raw_key)
+		self.assertEqual(_method.issuer, issuer)
+		self.assertEqual(_method.accountname, accountname)
+		self.assertEqual(_method.key_uri, key_uri)
+		db.session.add(method)
+		db.session.commit()
+		_method_id = _method.id
+		db.session.expunge(_method)
+		# Restore method from db
+		_method = TOTPMethod.query.get(_method_id)
+		self.assertEqual(_method.name, 'testname')
+		self.assertEqual(_method.raw_key, raw_key)
+		self.assertEqual(_method.issuer, issuer)
+		self.assertEqual(_method.accountname, accountname)
+		self.assertEqual(_method.key_uri, key_uri)
+
+	def test_totp_method_verify(self):
+		method = TOTPMethod(user=self.get_user())
+		counter = int(time.time()/30)
+		self.assertFalse(method.verify(''))
+		self.assertFalse(method.verify(_hotp(counter-2, method.raw_key)))
+		self.assertTrue(method.verify(_hotp(counter, method.raw_key)))
+		self.assertFalse(method.verify(_hotp(counter+2, method.raw_key)))
+
+	def test_webauthn_method(self):
+		data = get_fido2_test_cred(self)
+		method = WebauthnMethod(user=self.get_user(), cred=data, name='testname')
+		self.assertEqual(method.name, 'testname')
+		db.session.add(method)
+		db.session.commit()
+		method_id = method.id
+		method_cred = method.cred
+		db.session.expunge(method)
+		_method = WebauthnMethod.query.get(method_id)
+		self.assertEqual(_method.name, 'testname')
+		self.assertEqual(bytes(method_cred), bytes(_method.cred))
+		self.assertEqual(data.credential_id, _method.cred.credential_id)
+		self.assertEqual(data.public_key, _method.cred.public_key)
+		# We only test (de-)serialization here, as everything else is currently implemented in the views
diff --git a/tests/models/test_role.py b/tests/models/test_role.py
new file mode 100644
index 0000000000000000000000000000000000000000..8c786a75c1f818aaa2036dba7bba412ead3527f4
--- /dev/null
+++ b/tests/models/test_role.py
@@ -0,0 +1,134 @@
+import unittest
+
+from uffd.database import db
+from uffd.models import User, Role, RoleGroup, TOTPMethod
+from uffd.models.role import flatten_recursive
+
+from tests.utils import UffdTestCase
+
+class TestPrimitives(unittest.TestCase):
+	def test_flatten_recursive(self):
+		class Node:
+			def __init__(self, *neighbors):
+				self.neighbors = set(neighbors or set())
+
+		cycle = Node()
+		cycle.neighbors.add(cycle)
+		common = Node(cycle)
+		intermediate1 = Node(common)
+		intermediate2 = Node(common, intermediate1)
+		stub = Node()
+		backref = Node()
+		start1 = Node(intermediate1, intermediate2, stub, backref)
+		backref.neighbors.add(start1)
+		start2 = Node()
+		self.assertSetEqual(flatten_recursive({start1, start2}, 'neighbors'),
+		                    {start1, start2, backref, stub, intermediate1, intermediate2, common, cycle})
+		self.assertSetEqual(flatten_recursive(set(), 'neighbors'), set())
+
+class TestUserRoleAttributes(UffdTestCase):
+	def test_roles_effective(self):
+		db.session.add(User(loginname='service', is_service_user=True, primary_email_address='service@example.com', displayname='Service'))
+		db.session.commit()
+		user = self.get_user()
+		service_user = User.query.filter_by(loginname='service').one_or_none()
+		included_by_default_role = Role(name='included_by_default')
+		default_role = Role(name='default', is_default=True, included_roles=[included_by_default_role])
+		included_role = Role(name='included')
+		cycle_role = Role(name='cycle')
+		direct_role1 = Role(name='role1', members=[user, service_user], included_roles=[included_role, cycle_role])
+		direct_role2 = Role(name='role2', members=[user, service_user], included_roles=[included_role])
+		cycle_role.included_roles.append(direct_role1)
+		db.session.add_all([included_by_default_role, default_role, included_role, cycle_role, direct_role1, direct_role2])
+		self.assertSetEqual(user.roles_effective, {direct_role1, direct_role2, cycle_role, included_role, default_role, included_by_default_role})
+		self.assertSetEqual(service_user.roles_effective, {direct_role1, direct_role2, cycle_role, included_role})
+
+	def test_compute_groups(self):
+		user = self.get_user()
+		group1 = self.get_users_group()
+		group2 = self.get_access_group()
+		role1 = Role(name='role1', groups={group1: RoleGroup(group=group1)})
+		role2 = Role(name='role2', groups={group1: RoleGroup(group=group1), group2: RoleGroup(group=group2)})
+		db.session.add_all([role1, role2])
+		self.assertSetEqual(user.compute_groups(), set())
+		role1.members.append(user)
+		role2.members.append(user)
+		self.assertSetEqual(user.compute_groups(), {group1, group2})
+		role2.groups[group2].requires_mfa = True
+		self.assertSetEqual(user.compute_groups(), {group1})
+		db.session.add(TOTPMethod(user=user))
+		self.assertSetEqual(user.compute_groups(), {group1, group2})
+
+	def test_update_groups(self):
+		user = self.get_user()
+		group1 = self.get_users_group()
+		group2 = self.get_access_group()
+		role1 = Role(name='role1', members=[user], groups={group1: RoleGroup(group=group1)})
+		role2 = Role(name='role2', groups={group2: RoleGroup(group=group2)})
+		db.session.add_all([role1, role2])
+		user.groups = [group2]
+		groups_added, groups_removed = user.update_groups()
+		self.assertSetEqual(groups_added, {group1})
+		self.assertSetEqual(groups_removed, {group2})
+		self.assertSetEqual(set(user.groups), {group1})
+		groups_added, groups_removed = user.update_groups()
+		self.assertSetEqual(groups_added, set())
+		self.assertSetEqual(groups_removed, set())
+		self.assertSetEqual(set(user.groups), {group1})
+
+class TestRoleModel(UffdTestCase):
+	def test_members_effective(self):
+		db.session.add(User(loginname='service', is_service_user=True, primary_email_address='service@example.com', displayname='Service'))
+		db.session.commit()
+		user1 = self.get_user()
+		user2 = self.get_admin()
+		service = User.query.filter_by(loginname='service').one_or_none()
+		included_by_default_role = Role(name='included_by_default')
+		default_role = Role(name='default', is_default=True, included_roles=[included_by_default_role])
+		included_role = Role(name='included')
+		direct_role = Role(name='direct', members=[user1, user2, service], included_roles=[included_role])
+		empty_role = Role(name='empty', included_roles=[included_role])
+		self.assertSetEqual(included_by_default_role.members_effective, {user1, user2})
+		self.assertSetEqual(default_role.members_effective, {user1, user2})
+		self.assertSetEqual(included_role.members_effective, {user1, user2, service})
+		self.assertSetEqual(direct_role.members_effective, {user1, user2, service})
+		self.assertSetEqual(empty_role.members_effective, set())
+
+	def test_included_roles_recursive(self):
+		baserole = Role(name='base')
+		role1 = Role(name='role1', included_roles=[baserole])
+		role2 = Role(name='role2', included_roles=[baserole])
+		role3 = Role(name='role3', included_roles=[role1, role2])
+		self.assertSetEqual(role1.included_roles_recursive, {baserole})
+		self.assertSetEqual(role2.included_roles_recursive, {baserole})
+		self.assertSetEqual(role3.included_roles_recursive, {baserole, role1, role2})
+		baserole.included_roles.append(role1)
+		self.assertSetEqual(role3.included_roles_recursive, {baserole, role1, role2})
+
+	def test_groups_effective(self):
+		group1 = self.get_users_group()
+		group2 = self.get_access_group()
+		baserole = Role(name='base', groups={group1: RoleGroup(group=group1)})
+		role1 = Role(name='role1', groups={group2: RoleGroup(group=group2)}, included_roles=[baserole])
+		self.assertSetEqual(baserole.groups_effective, {group1})
+		self.assertSetEqual(role1.groups_effective, {group1, group2})
+
+	def test_update_member_groups(self):
+		user1 = self.get_user()
+		user1.update_groups()
+		user2 = self.get_admin()
+		user2.update_groups()
+		group1 = self.get_users_group()
+		group2 = self.get_access_group()
+		group3 = self.get_admin_group()
+		baserole = Role(name='base', members=[user1], groups={group1: RoleGroup(group=group1)})
+		role1 = Role(name='role1', members=[user2], groups={group2: RoleGroup(group=group2)}, included_roles=[baserole])
+		db.session.add_all([baserole, role1])
+		baserole.update_member_groups()
+		role1.update_member_groups()
+		self.assertSetEqual(set(user1.groups), {group1})
+		self.assertSetEqual(set(user2.groups), {group1, group2})
+		baserole.groups[group3] = RoleGroup()
+		baserole.update_member_groups()
+		self.assertSetEqual(set(user1.groups), {group1, group3})
+		self.assertSetEqual(set(user2.groups), {group1, group2, group3})
diff --git a/tests/test_services.py b/tests/models/test_services.py
similarity index 73%
rename from tests/test_services.py
rename to tests/models/test_services.py
index 5a1848bcc24181c799b08060f4b3cc7f1fc2ac35..82808db46dfd0bd71ebeb7f5462093de5cdf0208 100644
--- a/tests/test_services.py
+++ b/tests/models/test_services.py
@@ -1,14 +1,10 @@
-import datetime
-import unittest
-
-from flask import url_for
-
-from utils import dump, UffdTestCase
 from uffd.remailer import remailer
 from uffd.tasks import cleanup_task
 from uffd.database import db
 from uffd.models import Service, ServiceUser, User, UserEmail, RemailerMode
 
+from tests.utils import UffdTestCase
+
 class TestServiceUser(UffdTestCase):
 	def setUp(self):
 		super().setUp()
@@ -219,98 +215,3 @@ class TestServiceUser(UffdTestCase):
 		self.assertEqual(run_query(remailer_email2_1), {
 			(service2.id, user1.id),
 		})
-
-class TestServices(UffdTestCase):
-	def setUpApp(self):
-		self.app.config['SERVICES'] = [
-			{
-				'title': 'Service Title',
-				'subtitle': 'Service Subtitle',
-				'description': 'Short description of the service as plain text',
-				'url': 'https://example.com/',
-				'logo_url': '/static/fairy-dust-color.png',
-				'required_group': 'users',
-				'permission_levels': [
-					{'name': 'Moderator', 'required_group': 'moderators'},
-					{'name': 'Admin', 'required_group': 'uffd_admin'},
-				],
-				'confidential': True,
-				'groups': [
-					{'name': 'Group "crew_crew"', 'required_group': 'users'},
-					{'name': 'Group "crew_logistik"', 'required_group': 'uffd_admin'},
-				],
-				'infos': [
-					{'title': 'Documentation', 'html': '<p>Some information about the service as html</p>', 'required_group': 'users'},
-				],
-				'links': [
-					{'title': 'Link to an external site', 'url': '#', 'required_group': 'users'},
-				],
-			},
-			{
-				'title': 'Minimal Service Title',
-			}
-		]
-		self.app.config['SERVICES_PUBLIC'] = True
-
-	def test_overview(self):
-		r = self.client.get(path=url_for('service.overview'))
-		dump('service_overview_guest', r)
-		self.assertEqual(r.status_code, 200)
-		self.assertNotIn(b'https://example.com/', r.data)
-		self.login_as('user')
-		r = self.client.get(path=url_for('service.overview'))
-		dump('service_overview_user', r)
-		self.assertEqual(r.status_code, 200)
-		self.assertIn(b'https://example.com/', r.data)
-
-	def test_overview_disabled(self):
-		self.app.config['SERVICES'] = []
-		# Should return login page
-		r = self.client.get(path=url_for('service.overview'), follow_redirects=True)
-		dump('service_overview_disabled_guest', r)
-		self.assertEqual(r.status_code, 200)
-		self.assertIn(b'name="password"', r.data)
-		self.login_as('user')
-		# Should return access denied page
-		r = self.client.get(path=url_for('service.overview'), follow_redirects=True)
-		dump('service_overview_disabled_user', r)
-		self.assertEqual(r.status_code, 403)
-		self.login_as('admin')
-		# Should return (empty) overview page
-		r = self.client.get(path=url_for('service.overview'), follow_redirects=True)
-		dump('service_overview_disabled_admin', r)
-		self.assertEqual(r.status_code, 200)
-
-	def test_overview_nonpublic(self):
-		self.app.config['SERVICES_PUBLIC'] = False
-		# Should return login page
-		r = self.client.get(path=url_for('service.overview'), follow_redirects=True)
-		dump('service_overview_nonpublic_guest', r)
-		self.assertEqual(r.status_code, 200)
-		self.assertIn(b'name="password"', r.data)
-		self.login_as('user')
-		# Should return overview page
-		r = self.client.get(path=url_for('service.overview'), follow_redirects=True)
-		dump('service_overview_nonpublic_user', r)
-		self.assertEqual(r.status_code, 200)
-		self.login_as('admin')
-		# Should return overview page
-		r = self.client.get(path=url_for('service.overview'), follow_redirects=True)
-		dump('service_overview_nonpublic_admin', r)
-		self.assertEqual(r.status_code, 200)
-
-	def test_overview_public(self):
-		# Should return overview page
-		r = self.client.get(path=url_for('service.overview'), follow_redirects=True)
-		dump('service_overview_public_guest', r)
-		self.assertEqual(r.status_code, 200)
-		self.login_as('user')
-		# Should return overview page
-		r = self.client.get(path=url_for('service.overview'), follow_redirects=True)
-		dump('service_overview_public_user', r)
-		self.assertEqual(r.status_code, 200)
-		self.login_as('admin')
-		# Should return overview page
-		r = self.client.get(path=url_for('service.overview'), follow_redirects=True)
-		dump('service_overview_public_admin', r)
-		self.assertEqual(r.status_code, 200)
diff --git a/tests/models/test_signup.py b/tests/models/test_signup.py
new file mode 100644
index 0000000000000000000000000000000000000000..cfed4fb23324c8071e91ffd5b459dba6bb5eadc7
--- /dev/null
+++ b/tests/models/test_signup.py
@@ -0,0 +1,169 @@
+import datetime
+
+from uffd.database import db
+from uffd.models import Signup, User
+
+from tests.utils import UffdTestCase, db_flush
+
+def refetch_signup(signup):
+	db.session.add(signup)
+	db.session.commit()
+	id = signup.id
+	db.session.expunge(signup)
+	return Signup.query.get(id)
+
+# We assume in all tests that Signup.validate and Signup.password.verify do
+# not alter any state
+
+class TestSignupModel(UffdTestCase):
+	def assert_validate_valid(self, signup):
+		valid, msg = signup.validate()
+		self.assertTrue(valid)
+		self.assertIsInstance(msg, str)
+
+	def assert_validate_invalid(self, signup):
+		valid, msg = signup.validate()
+		self.assertFalse(valid)
+		self.assertIsInstance(msg, str)
+		self.assertNotEqual(msg, '')
+
+	def assert_finish_success(self, signup, password):
+		self.assertIsNone(signup.user)
+		user, msg = signup.finish(password)
+		db.session.commit()
+		self.assertIsNotNone(user)
+		self.assertIsInstance(msg, str)
+		self.assertIsNotNone(signup.user)
+
+	def assert_finish_failure(self, signup, password):
+		prev_id = signup.user_id
+		user, msg = signup.finish(password)
+		self.assertIsNone(user)
+		self.assertIsInstance(msg, str)
+		self.assertNotEqual(msg, '')
+		self.assertEqual(signup.user_id, prev_id)
+
+	def test_password(self):
+		signup = Signup(loginname='newuser', displayname='New User', mail='test@example.com')
+		self.assertFalse(signup.password.verify('notsecret'))
+		self.assertFalse(signup.password.verify(''))
+		self.assertFalse(signup.password.verify('wrongpassword'))
+		self.assertTrue(signup.set_password('notsecret'))
+		self.assertTrue(signup.password.verify('notsecret'))
+		self.assertFalse(signup.password.verify('wrongpassword'))
+
+	def test_expired(self):
+		# TODO: Find a better way to test this!
+		signup = Signup(loginname='newuser', displayname='New User', mail='test@example.com', password='notsecret')
+		self.assertFalse(signup.expired)
+		signup.created = created=datetime.datetime.utcnow() - datetime.timedelta(hours=49)
+		self.assertTrue(signup.expired)
+
+	def test_completed(self):
+		signup = Signup(loginname='newuser', displayname='New User', mail='test@example.com', password='notsecret')
+		self.assertFalse(signup.completed)
+		signup.finish('notsecret')
+		db.session.commit()
+		self.assertTrue(signup.completed)
+		signup = refetch_signup(signup)
+		self.assertTrue(signup.completed)
+
+	def test_validate(self):
+		signup = Signup(loginname='newuser', displayname='New User', mail='test@example.com', password='notsecret')
+		self.assert_validate_valid(signup)
+		self.assert_validate_valid(refetch_signup(signup))
+
+	def test_validate_completed(self):
+		signup = Signup(loginname='newuser', displayname='New User', mail='test@example.com', password='notsecret')
+		self.assert_finish_success(signup, 'notsecret')
+		self.assert_validate_invalid(signup)
+		self.assert_validate_invalid(refetch_signup(signup))
+
+	def test_validate_expired(self):
+		signup = Signup(loginname='newuser', displayname='New User', mail='test@example.com',
+		                password='notsecret', created=datetime.datetime.utcnow()-datetime.timedelta(hours=49))
+		self.assert_validate_invalid(signup)
+		self.assert_validate_invalid(refetch_signup(signup))
+
+	def test_validate_loginname(self):
+		signup = Signup(loginname='', displayname='New User', mail='test@example.com', password='notsecret')
+		self.assert_validate_invalid(signup)
+		self.assert_validate_invalid(refetch_signup(signup))
+
+	def test_validate_displayname(self):
+		signup = Signup(loginname='newuser', displayname='', mail='test@example.com', password='notsecret')
+		self.assert_validate_invalid(signup)
+		self.assert_validate_invalid(refetch_signup(signup))
+
+	def test_validate_mail(self):
+		signup = Signup(loginname='newuser', displayname='New User', mail='', password='notsecret')
+		self.assert_validate_invalid(signup)
+		self.assert_validate_invalid(refetch_signup(signup))
+
+	def test_validate_password(self):
+		signup = Signup(loginname='newuser', displayname='New User', mail='test@example.com')
+		self.assertFalse(signup.set_password(''))
+		self.assert_validate_invalid(signup)
+		self.assert_validate_invalid(refetch_signup(signup))
+
+	def test_validate_exists(self):
+		signup = Signup(loginname='testuser', displayname='New User', mail='test@example.com', password='notsecret')
+		self.assert_validate_invalid(signup)
+		self.assert_validate_invalid(refetch_signup(signup))
+
+	def test_finish(self):
+		signup = Signup(loginname='newuser', displayname='New User', mail='test@example.com', password='notsecret')
+		self.assert_finish_success(signup, 'notsecret')
+		user = User.query.filter_by(loginname='newuser').one_or_none()
+		self.assertEqual(user.loginname, 'newuser')
+		self.assertEqual(user.displayname, 'New User')
+		self.assertEqual(user.primary_email.address, 'test@example.com')
+
+	def test_finish_completed(self):
+		signup = Signup(loginname='newuser', displayname='New User', mail='test@example.com', password='notsecret')
+		self.assert_finish_success(signup, 'notsecret')
+		self.assert_finish_failure(refetch_signup(signup), 'notsecret')
+
+	def test_finish_expired(self):
+		# TODO: Find a better way to test this!
+		signup = Signup(loginname='newuser', displayname='New User', mail='test@example.com',
+		                password='notsecret', created=datetime.datetime.utcnow()-datetime.timedelta(hours=49))
+		self.assert_finish_failure(signup, 'notsecret')
+		self.assert_finish_failure(refetch_signup(signup), 'notsecret')
+
+	def test_finish_wrongpassword(self):
+		signup = Signup(loginname='newuser', displayname='New User', mail='test@example.com')
+		self.assert_finish_failure(signup, '')
+		self.assert_finish_failure(signup, 'wrongpassword')
+		signup = refetch_signup(signup)
+		self.assert_finish_failure(signup, '')
+		self.assert_finish_failure(signup, 'wrongpassword')
+		signup = Signup(loginname='newuser', displayname='New User', mail='test@example.com', password='notsecret')
+		self.assert_finish_failure(signup, 'wrongpassword')
+		self.assert_finish_failure(refetch_signup(signup), 'wrongpassword')
+
+	def test_finish_duplicate(self):
+		signup = Signup(loginname='testuser', displayname='New User', mail='test@example.com', password='notsecret')
+		self.assert_finish_failure(signup, 'notsecret')
+		self.assert_finish_failure(refetch_signup(signup), 'notsecret')
+
+	def test_duplicate(self):
+		signup = Signup(loginname='newuser', displayname='New User', mail='test1@example.com', password='notsecret')
+		self.assert_validate_valid(signup)
+		db.session.add(signup)
+		db.session.commit()
+		signup1_id = signup.id
+		signup = Signup(loginname='newuser', displayname='New User', mail='test2@example.com', password='notsecret')
+		self.assert_validate_valid(signup)
+		db.session.add(signup)
+		db.session.commit()
+		signup2_id = signup.id
+		db_flush()
+		signup = Signup.query.get(signup2_id)
+		self.assert_finish_success(signup, 'notsecret')
+		db.session.commit()
+		db_flush()
+		signup = Signup.query.get(signup1_id)
+		self.assert_finish_failure(signup, 'notsecret')
+		user = User.query.filter_by(loginname='newuser').one_or_none()
+		self.assertEqual(user.primary_email.address, 'test2@example.com')
diff --git a/tests/models/test_user.py b/tests/models/test_user.py
new file mode 100644
index 0000000000000000000000000000000000000000..42fabfcf296b14422ff51a31f997a7daacc23a87
--- /dev/null
+++ b/tests/models/test_user.py
@@ -0,0 +1,189 @@
+import datetime
+
+import sqlalchemy
+
+from uffd.database import db
+from uffd.models import User, UserEmail, Group
+
+from tests.utils import UffdTestCase
+
+class TestUserModel(UffdTestCase):
+	def test_has_permission(self):
+		user_ = self.get_user() # has 'users' and 'uffd_access' group
+		admin = self.get_admin() # has 'users', 'uffd_access' and 'uffd_admin' group
+		self.assertTrue(user_.has_permission(None))
+		self.assertTrue(admin.has_permission(None))
+		self.assertTrue(user_.has_permission('users'))
+		self.assertTrue(admin.has_permission('users'))
+		self.assertFalse(user_.has_permission('notagroup'))
+		self.assertFalse(admin.has_permission('notagroup'))
+		self.assertFalse(user_.has_permission('uffd_admin'))
+		self.assertTrue(admin.has_permission('uffd_admin'))
+		self.assertFalse(user_.has_permission(['uffd_admin']))
+		self.assertTrue(admin.has_permission(['uffd_admin']))
+		self.assertFalse(user_.has_permission(['uffd_admin', 'notagroup']))
+		self.assertTrue(admin.has_permission(['uffd_admin', 'notagroup']))
+		self.assertFalse(user_.has_permission(['notagroup', 'uffd_admin']))
+		self.assertTrue(admin.has_permission(['notagroup', 'uffd_admin']))
+		self.assertTrue(user_.has_permission(['uffd_admin', 'users']))
+		self.assertTrue(admin.has_permission(['uffd_admin', 'users']))
+		self.assertTrue(user_.has_permission([['uffd_admin', 'users'], ['users', 'uffd_access']]))
+		self.assertTrue(admin.has_permission([['uffd_admin', 'users'], ['users', 'uffd_access']]))
+		self.assertFalse(user_.has_permission(['uffd_admin', ['users', 'notagroup']]))
+		self.assertTrue(admin.has_permission(['uffd_admin', ['users', 'notagroup']]))
+
+	def test_unix_uid_generation(self):
+		self.app.config['USER_MIN_UID'] = 10000
+		self.app.config['USER_MAX_UID'] = 18999
+		self.app.config['USER_SERVICE_MIN_UID'] = 19000
+		self.app.config['USER_SERVICE_MAX_UID'] =19999
+		User.query.delete()
+		db.session.commit()
+		user0 = User(loginname='user0', displayname='user0', primary_email_address='user0@example.com')
+		user1 = User(loginname='user1', displayname='user1', primary_email_address='user1@example.com')
+		user2 = User(loginname='user2', displayname='user2', primary_email_address='user2@example.com')
+		db.session.add_all([user0, user1, user2])
+		db.session.commit()
+		self.assertEqual(user0.unix_uid, 10000)
+		self.assertEqual(user1.unix_uid, 10001)
+		self.assertEqual(user2.unix_uid, 10002)
+		db.session.delete(user1)
+		db.session.commit()
+		user3 = User(loginname='user3', displayname='user3', primary_email_address='user3@example.com')
+		db.session.add(user3)
+		db.session.commit()
+		self.assertEqual(user3.unix_uid, 10003)
+		service0 = User(loginname='service0', displayname='service0', primary_email_address='service0@example.com', is_service_user=True)
+		service1 = User(loginname='service1', displayname='service1', primary_email_address='service1@example.com', is_service_user=True)
+		db.session.add_all([service0, service1])
+		db.session.commit()
+		self.assertEqual(service0.unix_uid, 19000)
+		self.assertEqual(service1.unix_uid, 19001)
+
+	def test_unix_uid_generation_overlapping(self):
+		self.app.config['USER_MIN_UID'] = 10000
+		self.app.config['USER_MAX_UID'] = 19999
+		self.app.config['USER_SERVICE_MIN_UID'] = 10000
+		self.app.config['USER_SERVICE_MAX_UID'] = 19999
+		User.query.delete()
+		db.session.commit()
+		user0 = User(loginname='user0', displayname='user0', primary_email_address='user0@example.com')
+		service0 = User(loginname='service0', displayname='service0', primary_email_address='service0@example.com', is_service_user=True)
+		user1 = User(loginname='user1', displayname='user1', primary_email_address='user1@example.com')
+		db.session.add_all([user0, service0, user1])
+		db.session.commit()
+		self.assertEqual(user0.unix_uid, 10000)
+		self.assertEqual(service0.unix_uid, 10001)
+		self.assertEqual(user1.unix_uid, 10002)
+
+	def test_unix_uid_generation_overflow(self):
+		self.app.config['USER_MIN_UID'] = 10000
+		self.app.config['USER_MAX_UID'] = 10001
+		User.query.delete()
+		db.session.commit()
+		user0 = User(loginname='user0', displayname='user0', primary_email_address='user0@example.com')
+		user1 = User(loginname='user1', displayname='user1', primary_email_address='user1@example.com')
+		db.session.add_all([user0, user1])
+		db.session.commit()
+		self.assertEqual(user0.unix_uid, 10000)
+		self.assertEqual(user1.unix_uid, 10001)
+		with self.assertRaises(sqlalchemy.exc.IntegrityError):
+			user2 = User(loginname='user2', displayname='user2', primary_email_address='user2@example.com')
+			db.session.add(user2)
+			db.session.commit()
+
+	def test_init_primary_email_address(self):
+		user = User(primary_email_address='foobar@example.com')
+		self.assertEqual(user.primary_email.address, 'foobar@example.com')
+		self.assertEqual(user.primary_email.verified, True)
+		self.assertEqual(user.primary_email.user, user)
+		user = User(primary_email_address='invalid')
+		self.assertEqual(user.primary_email.address, 'invalid')
+		self.assertEqual(user.primary_email.verified, True)
+		self.assertEqual(user.primary_email.user, user)
+
+	def test_set_primary_email_address(self):
+		user = User()
+		self.assertFalse(user.set_primary_email_address('invalid'))
+		self.assertIsNone(user.primary_email)
+		self.assertEqual(len(user.all_emails), 0)
+		self.assertTrue(user.set_primary_email_address('foobar@example.com'))
+		self.assertEqual(user.primary_email.address, 'foobar@example.com')
+		self.assertEqual(len(user.all_emails), 1)
+		self.assertFalse(user.set_primary_email_address('invalid'))
+		self.assertEqual(user.primary_email.address, 'foobar@example.com')
+		self.assertEqual(len(user.all_emails), 1)
+		self.assertTrue(user.set_primary_email_address('other@example.com'))
+		self.assertEqual(user.primary_email.address, 'other@example.com')
+		self.assertEqual(len(user.all_emails), 2)
+		self.assertEqual({user.all_emails[0].address, user.all_emails[1].address}, {'foobar@example.com', 'other@example.com'})
+
+class TestUserEmailModel(UffdTestCase):
+	def test_set_address(self):
+		email = UserEmail()
+		self.assertFalse(email.set_address('invalid'))
+		self.assertIsNone(email.address)
+		self.assertFalse(email.set_address(''))
+		self.assertFalse(email.set_address('@'))
+		self.app.config['REMAILER_DOMAIN'] = 'remailer.example.com'
+		self.assertFalse(email.set_address('foobar@remailer.example.com'))
+		self.assertFalse(email.set_address('v1-1-testuser@remailer.example.com'))
+		self.assertFalse(email.set_address('v1-1-testuser @ remailer.example.com'))
+		self.assertFalse(email.set_address('v1-1-testuser@REMAILER.example.com'))
+		self.assertFalse(email.set_address('v1-1-testuser@foobar@remailer.example.com'))
+		self.assertTrue(email.set_address('foobar@example.com'))
+		self.assertEqual(email.address, 'foobar@example.com')
+
+	def test_verification(self):
+		email = UserEmail(address='foo@example.com')
+		self.assertFalse(email.finish_verification('test'))
+		secret = email.start_verification()
+		self.assertTrue(email.verification_secret)
+		self.assertTrue(email.verification_secret.verify(secret))
+		self.assertFalse(email.verification_expired)
+		self.assertFalse(email.finish_verification('test'))
+		orig_expires = email.verification_expires
+		email.verification_expires = datetime.datetime.utcnow() - datetime.timedelta(days=1)
+		self.assertFalse(email.finish_verification(secret))
+		email.verification_expires = orig_expires
+		self.assertTrue(email.finish_verification(secret))
+		self.assertFalse(email.verification_secret)
+		self.assertTrue(email.verification_expired)
+
+class TestGroupModel(UffdTestCase):
+	def test_unix_gid_generation(self):
+		self.app.config['GROUP_MIN_GID'] = 20000
+		self.app.config['GROUP_MAX_GID'] = 49999
+		Group.query.delete()
+		db.session.commit()
+		group0 = Group(name='group0', description='group0')
+		group1 = Group(name='group1', description='group1')
+		group2 = Group(name='group2', description='group2')
+		db.session.add_all([group0, group1, group2])
+		db.session.commit()
+		self.assertEqual(group0.unix_gid, 20000)
+		self.assertEqual(group1.unix_gid, 20001)
+		self.assertEqual(group2.unix_gid, 20002)
+		db.session.delete(group1)
+		db.session.commit()
+		group3 = Group(name='group3', description='group3')
+		db.session.add(group3)
+		db.session.commit()
+		self.assertEqual(group3.unix_gid, 20003)
+
+	def test_unix_gid_generation(self):
+		self.app.config['GROUP_MIN_GID'] = 20000
+		self.app.config['GROUP_MAX_GID'] = 20001
+		Group.query.delete()
+		db.session.commit()
+		group0 = Group(name='group0', description='group0')
+		group1 = Group(name='group1', description='group1')
+		db.session.add_all([group0, group1])
+		db.session.commit()
+		self.assertEqual(group0.unix_gid, 20000)
+		self.assertEqual(group1.unix_gid, 20001)
+		db.session.commit()
+		with self.assertRaises(sqlalchemy.exc.IntegrityError):
+			group2 = Group(name='group2', description='group2')
+			db.session.add(group2)
+			db.session.commit()
diff --git a/tests/test_ratelimit.py b/tests/test_ratelimit.py
index a10903f4055ecaa93c472d6c12fdb0333cd4e6e7..377e1b4c9c792dc3f92f92179258c13d74bdb86f 100644
--- a/tests/test_ratelimit.py
+++ b/tests/test_ratelimit.py
@@ -1,10 +1,6 @@
-import time
+from uffd.models.ratelimit import get_addrkey, format_delay, Ratelimit
 
-from flask import Flask, Blueprint, session, url_for
-
-from uffd.models.ratelimit import get_addrkey, format_delay, Ratelimit, RatelimitEvent
-
-from utils import UffdTestCase
+from tests.utils import UffdTestCase
 
 class TestRatelimit(UffdTestCase):
 	def test_limiting(self):
diff --git a/tests/test_remailer.py b/tests/test_remailer.py
index 239f3743d1669a543213f31a650ca06cc7f023f0..3600dbedfd17371dfce07d940db573ec27f9e9d5 100644
--- a/tests/test_remailer.py
+++ b/tests/test_remailer.py
@@ -1,6 +1,6 @@
 from uffd.remailer import remailer
 
-from utils import UffdTestCase
+from tests.utils import UffdTestCase
 
 USER_ID = 1234
 SERVICE1_ID = 4223
diff --git a/tests/test_role.py b/tests/test_role.py
deleted file mode 100644
index 3283d2734ba025f6db168a29363217ed5cb5c985..0000000000000000000000000000000000000000
--- a/tests/test_role.py
+++ /dev/null
@@ -1,429 +0,0 @@
-import datetime
-import time
-import unittest
-
-from flask import url_for, session
-
-from uffd import create_app, db
-from uffd.models import User, Group, Role, RoleGroup, TOTPMethod
-from uffd.models.role import flatten_recursive
-
-from utils import dump, UffdTestCase
-
-class TestPrimitives(unittest.TestCase):
-	def test_flatten_recursive(self):
-		class Node:
-			def __init__(self, *neighbors):
-				self.neighbors = set(neighbors or set())
-
-		cycle = Node()
-		cycle.neighbors.add(cycle)
-		common = Node(cycle)
-		intermediate1 = Node(common)
-		intermediate2 = Node(common, intermediate1)
-		stub = Node()
-		backref = Node()
-		start1 = Node(intermediate1, intermediate2, stub, backref)
-		backref.neighbors.add(start1)
-		start2 = Node()
-		self.assertSetEqual(flatten_recursive({start1, start2}, 'neighbors'),
-		                    {start1, start2, backref, stub, intermediate1, intermediate2, common, cycle})
-		self.assertSetEqual(flatten_recursive(set(), 'neighbors'), set())
-
-class TestUserRoleAttributes(UffdTestCase):
-	def test_roles_effective(self):
-		db.session.add(User(loginname='service', is_service_user=True, primary_email_address='service@example.com', displayname='Service'))
-		db.session.commit()
-		user = self.get_user()
-		service_user = User.query.filter_by(loginname='service').one_or_none()
-		included_by_default_role = Role(name='included_by_default')
-		default_role = Role(name='default', is_default=True, included_roles=[included_by_default_role])
-		included_role = Role(name='included')
-		cycle_role = Role(name='cycle')
-		direct_role1 = Role(name='role1', members=[user, service_user], included_roles=[included_role, cycle_role])
-		direct_role2 = Role(name='role2', members=[user, service_user], included_roles=[included_role])
-		cycle_role.included_roles.append(direct_role1)
-		db.session.add_all([included_by_default_role, default_role, included_role, cycle_role, direct_role1, direct_role2])
-		self.assertSetEqual(user.roles_effective, {direct_role1, direct_role2, cycle_role, included_role, default_role, included_by_default_role})
-		self.assertSetEqual(service_user.roles_effective, {direct_role1, direct_role2, cycle_role, included_role})
-
-	def test_compute_groups(self):
-		user = self.get_user()
-		group1 = self.get_users_group()
-		group2 = self.get_access_group()
-		role1 = Role(name='role1', groups={group1: RoleGroup(group=group1)})
-		role2 = Role(name='role2', groups={group1: RoleGroup(group=group1), group2: RoleGroup(group=group2)})
-		db.session.add_all([role1, role2])
-		self.assertSetEqual(user.compute_groups(), set())
-		role1.members.append(user)
-		role2.members.append(user)
-		self.assertSetEqual(user.compute_groups(), {group1, group2})
-		role2.groups[group2].requires_mfa = True
-		self.assertSetEqual(user.compute_groups(), {group1})
-		db.session.add(TOTPMethod(user=user))
-		self.assertSetEqual(user.compute_groups(), {group1, group2})
-
-	def test_update_groups(self):
-		user = self.get_user()
-		group1 = self.get_users_group()
-		group2 = self.get_access_group()
-		role1 = Role(name='role1', members=[user], groups={group1: RoleGroup(group=group1)})
-		role2 = Role(name='role2', groups={group2: RoleGroup(group=group2)})
-		db.session.add_all([role1, role2])
-		user.groups = [group2]
-		groups_added, groups_removed = user.update_groups()
-		self.assertSetEqual(groups_added, {group1})
-		self.assertSetEqual(groups_removed, {group2})
-		self.assertSetEqual(set(user.groups), {group1})
-		groups_added, groups_removed = user.update_groups()
-		self.assertSetEqual(groups_added, set())
-		self.assertSetEqual(groups_removed, set())
-		self.assertSetEqual(set(user.groups), {group1})
-
-class TestRoleModel(UffdTestCase):
-	def test_members_effective(self):
-		db.session.add(User(loginname='service', is_service_user=True, primary_email_address='service@example.com', displayname='Service'))
-		db.session.commit()
-		user1 = self.get_user()
-		user2 = self.get_admin()
-		service = User.query.filter_by(loginname='service').one_or_none()
-		included_by_default_role = Role(name='included_by_default')
-		default_role = Role(name='default', is_default=True, included_roles=[included_by_default_role])
-		included_role = Role(name='included')
-		direct_role = Role(name='direct', members=[user1, user2, service], included_roles=[included_role])
-		empty_role = Role(name='empty', included_roles=[included_role])
-		self.assertSetEqual(included_by_default_role.members_effective, {user1, user2})
-		self.assertSetEqual(default_role.members_effective, {user1, user2})
-		self.assertSetEqual(included_role.members_effective, {user1, user2, service})
-		self.assertSetEqual(direct_role.members_effective, {user1, user2, service})
-		self.assertSetEqual(empty_role.members_effective, set())
-
-	def test_included_roles_recursive(self):
-		baserole = Role(name='base')
-		role1 = Role(name='role1', included_roles=[baserole])
-		role2 = Role(name='role2', included_roles=[baserole])
-		role3 = Role(name='role3', included_roles=[role1, role2])
-		self.assertSetEqual(role1.included_roles_recursive, {baserole})
-		self.assertSetEqual(role2.included_roles_recursive, {baserole})
-		self.assertSetEqual(role3.included_roles_recursive, {baserole, role1, role2})
-		baserole.included_roles.append(role1)
-		self.assertSetEqual(role3.included_roles_recursive, {baserole, role1, role2})
-
-	def test_groups_effective(self):
-		group1 = self.get_users_group()
-		group2 = self.get_access_group()
-		baserole = Role(name='base', groups={group1: RoleGroup(group=group1)})
-		role1 = Role(name='role1', groups={group2: RoleGroup(group=group2)}, included_roles=[baserole])
-		self.assertSetEqual(baserole.groups_effective, {group1})
-		self.assertSetEqual(role1.groups_effective, {group1, group2})
-
-	def test_update_member_groups(self):
-		user1 = self.get_user()
-		user1.update_groups()
-		user2 = self.get_admin()
-		user2.update_groups()
-		group1 = self.get_users_group()
-		group2 = self.get_access_group()
-		group3 = self.get_admin_group()
-		baserole = Role(name='base', members=[user1], groups={group1: RoleGroup(group=group1)})
-		role1 = Role(name='role1', members=[user2], groups={group2: RoleGroup(group=group2)}, included_roles=[baserole])
-		db.session.add_all([baserole, role1])
-		baserole.update_member_groups()
-		role1.update_member_groups()
-		self.assertSetEqual(set(user1.groups), {group1})
-		self.assertSetEqual(set(user2.groups), {group1, group2})
-		baserole.groups[group3] = RoleGroup()
-		baserole.update_member_groups()
-		self.assertSetEqual(set(user1.groups), {group1, group3})
-		self.assertSetEqual(set(user2.groups), {group1, group2, group3})
-
-class TestRoleViews(UffdTestCase):
-	def setUp(self):
-		super().setUp()
-		self.login_as('admin')
-
-	def test_index(self):
-		db.session.add(Role(name='base', description='Base role description'))
-		db.session.add(Role(name='test1', description='Test1 role description'))
-		db.session.commit()
-		r = self.client.get(path=url_for('role.index'), follow_redirects=True)
-		dump('role_index', r)
-		self.assertEqual(r.status_code, 200)
-
-	def test_index_empty(self):
-		r = self.client.get(path=url_for('role.index'), follow_redirects=True)
-		dump('role_index_empty', r)
-		self.assertEqual(r.status_code, 200)
-
-	def test_show(self):
-		role = Role(name='base', description='Base role description')
-		db.session.add(role)
-		db.session.commit()
-		r = self.client.get(path=url_for('role.show', roleid=role.id), follow_redirects=True)
-		dump('role_show', r)
-		self.assertEqual(r.status_code, 200)
-
-	def test_new(self):
-		r = self.client.get(path=url_for('role.new'), follow_redirects=True)
-		dump('role_new', r)
-		self.assertEqual(r.status_code, 200)
-
-	def test_update(self):
-		role = Role(name='base', description='Base role description')
-		db.session.add(role)
-		db.session.commit()
-		role.groups[self.get_admin_group()] = RoleGroup()
-		db.session.commit()
-		self.assertEqual(role.name, 'base')
-		self.assertEqual(role.description, 'Base role description')
-		self.assertSetEqual(set(role.groups), {self.get_admin_group()})
-		r = self.client.post(path=url_for('role.update', roleid=role.id),
-			data={'name': 'base1', 'description': 'Base role description1', 'moderator-group': '', 'group-%d'%self.get_users_group().id: '1', 'group-%d'%self.get_access_group().id: '1'},
-			follow_redirects=True)
-		dump('role_update', r)
-		self.assertEqual(r.status_code, 200)
-		role = Role.query.get(role.id)
-		self.assertEqual(role.name, 'base1')
-		self.assertEqual(role.description, 'Base role description1')
-		self.assertSetEqual(set(role.groups), {self.get_access_group(), self.get_users_group()})
-		# TODO: verify that group memberships are updated
-
-	def test_create(self):
-		self.assertIsNone(Role.query.filter_by(name='base').first())
-		r = self.client.post(path=url_for('role.update'),
-			data={'name': 'base', 'description': 'Base role description', 'moderator-group': '', 'group-%d'%self.get_users_group().id: '1', 'group-%d'%self.get_access_group().id: '1'},
-			follow_redirects=True)
-		dump('role_create', r)
-		self.assertEqual(r.status_code, 200)
-		role = Role.query.filter_by(name='base').first()
-		self.assertIsNotNone(role)
-		self.assertEqual(role.name, 'base')
-		self.assertEqual(role.description, 'Base role description')
-		self.assertSetEqual(set(role.groups), {self.get_access_group(), self.get_users_group()})
-		# TODO: verify that group memberships are updated (currently not possible with ldap mock!)
-
-	def test_create_with_moderator_group(self):
-		self.assertIsNone(Role.query.filter_by(name='base').first())
-		r = self.client.post(path=url_for('role.update'),
-			data={'name': 'base', 'description': 'Base role description', 'moderator-group': self.get_admin_group().id, 'group-%d'%self.get_users_group().id: '1', 'group-%d'%self.get_access_group().id: '1'},
-			follow_redirects=True)
-		self.assertEqual(r.status_code, 200)
-		role = Role.query.filter_by(name='base').first()
-		self.assertIsNotNone(role)
-		self.assertEqual(role.name, 'base')
-		self.assertEqual(role.description, 'Base role description')
-		self.assertEqual(role.moderator_group.name, 'uffd_admin')
-		self.assertSetEqual(set(role.groups), {self.get_access_group(), self.get_users_group()})
-		# TODO: verify that group memberships are updated (currently not possible with ldap mock!)
-
-	def test_delete(self):
-		role = Role(name='base', description='Base role description')
-		db.session.add(role)
-		db.session.commit()
-		role_id = role.id
-		self.assertIsNotNone(Role.query.get(role_id))
-		r = self.client.get(path=url_for('role.delete', roleid=role.id), follow_redirects=True)
-		dump('role_delete', r)
-		self.assertEqual(r.status_code, 200)
-		self.assertIsNone(Role.query.get(role_id))
-		# TODO: verify that group memberships are updated (currently not possible with ldap mock!)
-
-	def test_set_default(self):
-		db.session.add(User(loginname='service', is_service_user=True, primary_email_address='service@example.com', displayname='Service'))
-		db.session.commit()
-		role = Role(name='test')
-		db.session.add(role)
-		role.groups[self.get_admin_group()] = RoleGroup()
-		user1 = self.get_user()
-		user2 = self.get_admin()
-		service_user = User.query.filter_by(loginname='service').one_or_none()
-		self.assertSetEqual(set(self.get_user().roles_effective), set())
-		self.assertSetEqual(set(self.get_admin().roles_effective), set())
-		self.assertSetEqual(set(service_user.roles_effective), set())
-		role.members.append(self.get_user())
-		role.members.append(service_user)
-		self.assertSetEqual(set(self.get_user().roles_effective), {role})
-		self.assertSetEqual(set(self.get_admin().roles_effective), set())
-		self.assertSetEqual(set(service_user.roles_effective), {role})
-		db.session.commit()
-		role_id = role.id
-		self.assertSetEqual(set(role.members), {self.get_user(), service_user})
-		r = self.client.get(path=url_for('role.set_default', roleid=role.id), follow_redirects=True)
-		dump('role_set_default', r)
-		self.assertEqual(r.status_code, 200)
-		role = Role.query.get(role_id)
-		service_user = User.query.filter_by(loginname='service').one_or_none()
-		self.assertSetEqual(set(role.members), {service_user})
-		self.assertSetEqual(set(self.get_user().roles_effective), {role})
-		self.assertSetEqual(set(self.get_admin().roles_effective), {role})
-
-	def test_unset_default(self):
-		admin_role = Role(name='admin', is_default=True)
-		db.session.add(admin_role)
-		admin_role.groups[self.get_admin_group()] = RoleGroup()
-		db.session.add(User(loginname='service', is_service_user=True, primary_email_address='service@example.com', displayname='Service'))
-		db.session.commit()
-		role = Role(name='test', is_default=True)
-		db.session.add(role)
-		service_user = User.query.filter_by(loginname='service').one_or_none()
-		role.members.append(service_user)
-		self.assertSetEqual(set(self.get_user().roles_effective), {role, admin_role})
-		self.assertSetEqual(set(self.get_admin().roles_effective), {role, admin_role})
-		self.assertSetEqual(set(service_user.roles_effective), {role})
-		db.session.commit()
-		role_id = role.id
-		admin_role_id = admin_role.id
-		self.assertSetEqual(set(role.members), {service_user})
-		r = self.client.get(path=url_for('role.unset_default', roleid=role.id), follow_redirects=True)
-		dump('role_unset_default', r)
-		self.assertEqual(r.status_code, 200)
-		role = Role.query.get(role_id)
-		admin_role = Role.query.get(admin_role_id)
-		service_user = User.query.filter_by(loginname='service').one_or_none()
-		self.assertSetEqual(set(role.members), {service_user})
-		self.assertSetEqual(set(self.get_user().roles_effective), {admin_role})
-		self.assertSetEqual(set(self.get_admin().roles_effective), {admin_role})
-
-class TestRoleCLI(UffdTestCase):
-	def setUp(self):
-		super().setUp()
-		role = Role(name='admin')
-		db.session.add(role)
-		role.groups[self.get_admin_group()] = RoleGroup(group=self.get_admin_group())
-		role.members.append(self.get_admin())
-		role = Role(name='base', is_default=True)
-		db.session.add(role)
-		role.groups[self.get_access_group()] = RoleGroup(group=self.get_access_group())
-		db.session.add(Role(name='test'))
-		for user in User.query:
-			user.update_groups()
-		db.session.commit()
-		self.client.__exit__(None, None, None)
-
-	def test_list(self):
-		result = self.app.test_cli_runner().invoke(args=['role', 'list'])
-		self.assertEqual(result.exit_code, 0)
-
-	def test_show(self):
-		result = self.app.test_cli_runner().invoke(args=['role', 'show', 'admin'])
-		self.assertEqual(result.exit_code, 0)
-		result = self.app.test_cli_runner().invoke(args=['role', 'show', 'doesnotexist'])
-		self.assertEqual(result.exit_code, 1)
-
-	def test_create(self):
-		result = self.app.test_cli_runner().invoke(args=['role', 'create', 'test']) # conflicting name
-		self.assertEqual(result.exit_code, 1)
-		result = self.app.test_cli_runner().invoke(args=['role', 'create', 'newrole', '--moderator-group', 'doesnotexist']) # invalid mod group
-		self.assertEqual(result.exit_code, 1)
-		result = self.app.test_cli_runner().invoke(args=['role', 'create', 'newrole', '--add-group', 'doesnotexist']) # invalid group
-		self.assertEqual(result.exit_code, 1)
-		result = self.app.test_cli_runner().invoke(args=['role', 'create', 'newrole', '--add-role', 'doesnotexist']) # invalid role
-		self.assertEqual(result.exit_code, 1)
-		result = self.app.test_cli_runner().invoke(args=['role', 'create', 'newrole', '--description', 'Role description',
-		                                                 '--moderator-group', 'uffd_admin', '--add-group', 'users',
-		                                                 '--add-role', 'admin'])
-		self.assertEqual(result.exit_code, 0)
-		with self.app.test_request_context():
-			role = Role.query.filter_by(name='newrole').one()
-			self.assertIsNotNone(role)
-			self.assertEqual(role.description, 'Role description')
-			self.assertEqual(role.moderator_group, self.get_admin_group())
-			self.assertEqual(list(role.groups), [self.get_users_group()])
-			self.assertEqual(role.included_roles, Role.query.filter_by(name='admin').all())
-		with self.app.test_request_context():
-			for user in User.query:
-				self.assertNotIn(self.get_users_group(), user.groups)
-		result = self.app.test_cli_runner().invoke(args=['role', 'create', 'newbase', '--add-group', 'users', '--default'])
-		self.assertEqual(result.exit_code, 0)
-		with self.app.test_request_context():
-			for user in User.query:
-				self.assertIn(self.get_users_group(), user.groups)
-
-	def test_update(self):
-		result = self.app.test_cli_runner().invoke(args=['role', 'update', 'doesnotexist', '--description', 'New description'])
-		self.assertEqual(result.exit_code, 1)
-		result = self.app.test_cli_runner().invoke(args=['role', 'update', 'test', '--add-group', 'doesnotexist'])
-		self.assertEqual(result.exit_code, 1)
-		result = self.app.test_cli_runner().invoke(args=['role', 'update', 'test', '--remove-group', 'doesnotexist'])
-		self.assertEqual(result.exit_code, 1)
-		result = self.app.test_cli_runner().invoke(args=['role', 'update', 'test', '--add-role', 'doesnotexist'])
-		self.assertEqual(result.exit_code, 1)
-		result = self.app.test_cli_runner().invoke(args=['role', 'update', 'test', '--remove-role', 'doesnotexist'])
-		self.assertEqual(result.exit_code, 1)
-		result = self.app.test_cli_runner().invoke(args=['role', 'update', 'test', '--moderator-group', 'doesnotexist'])
-		self.assertEqual(result.exit_code, 1)
-		result = self.app.test_cli_runner().invoke(args=['role', 'update', 'base', '--description', 'New description',
-		                                                 '--moderator-group', 'uffd_admin', '--add-group', 'users',
-		                                                 '--remove-group', 'uffd_access', '--add-role', 'admin'])
-		self.assertEqual(result.exit_code, 0)
-		with self.app.test_request_context():
-			role = Role.query.filter_by(name='base').first()
-			self.assertIsNotNone(role)
-			self.assertEqual(role.description, 'New description')
-			self.assertEqual(role.moderator_group, self.get_admin_group())
-			self.assertEqual(list(role.groups), [self.get_users_group()])
-			self.assertEqual(role.included_roles, Role.query.filter_by(name='admin').all())
-			self.assertEqual(set(self.get_user().groups), {self.get_users_group(), self.get_admin_group()})
-		result = self.app.test_cli_runner().invoke(args=['role', 'update', 'base', '--no-moderator-group', '--clear-groups',
-		                                                 '--add-group', 'uffd_access', '--remove-role', 'admin',
-		                                                 '--add-role', 'test'])
-		self.assertEqual(result.exit_code, 0)
-		with self.app.test_request_context():
-			role = Role.query.filter_by(name='base').first()
-			self.assertIsNone(role.moderator_group)
-			self.assertEqual(list(role.groups), [self.get_access_group()])
-			self.assertEqual(role.included_roles, Role.query.filter_by(name='test').all())
-			self.assertEqual(set(self.get_user().groups), {self.get_access_group()})
-		result = self.app.test_cli_runner().invoke(args=['role', 'update', 'base', '--clear-roles'])
-		self.assertEqual(result.exit_code, 0)
-		with self.app.test_request_context():
-			role = Role.query.filter_by(name='base').first()
-			self.assertEqual(role.included_roles, [])
-			self.assertEqual(role.is_default, True)
-			self.assertEqual(set(self.get_user().groups), {self.get_access_group()})
-		result = self.app.test_cli_runner().invoke(args=['role', 'update', 'base', '--no-default'])
-		self.assertEqual(result.exit_code, 0)
-		with self.app.test_request_context():
-			role = Role.query.filter_by(name='base').first()
-			self.assertEqual(role.is_default, False)
-			self.assertEqual(set(self.get_user().groups), set())
-		result = self.app.test_cli_runner().invoke(args=['role', 'update', 'base', '--default'])
-		self.assertEqual(result.exit_code, 0)
-		with self.app.test_request_context():
-			role = Role.query.filter_by(name='base').first()
-			self.assertEqual(role.is_default, True)
-			self.assertEqual(set(self.get_user().groups), {self.get_access_group()})
-
-	# Regression test for https://git.cccv.de/uffd/uffd/-/issues/156
-	def test_update_without_description(self):
-		with self.app.test_request_context():
-			role = Role.query.filter_by(name='test').first()
-			role.description = 'Test description'
-			db.session.commit()
-		result = self.app.test_cli_runner().invoke(args=['role', 'update', 'test', '--clear-groups'])
-		self.assertEqual(result.exit_code, 0)
-		with self.app.test_request_context():
-			role = Role.query.filter_by(name='test').first()
-			self.assertEqual(role.description, 'Test description')
-
-	def test_delete(self):
-		with self.app.test_request_context():
-			self.assertIsNotNone(Role.query.filter_by(name='test').first())
-		result = self.app.test_cli_runner().invoke(args=['role', 'delete', 'test'])
-		self.assertEqual(result.exit_code, 0)
-		with self.app.test_request_context():
-			self.assertIsNone(Role.query.filter_by(name='test').first())
-		result = self.app.test_cli_runner().invoke(args=['role', 'delete', 'doesnotexist'])
-		self.assertEqual(result.exit_code, 1)
-		with self.app.test_request_context():
-			self.assertIn(self.get_admin_group(), self.get_admin().groups)
-		result = self.app.test_cli_runner().invoke(args=['role', 'delete', 'admin'])
-		self.assertEqual(result.exit_code, 0)
-		with self.app.test_request_context():
-			self.assertNotIn(self.get_admin_group(), self.get_admin().groups)
-		with self.app.test_request_context():
-			self.assertIn(self.get_access_group(), self.get_user().groups)
-		result = self.app.test_cli_runner().invoke(args=['role', 'delete', 'base'])
-		self.assertEqual(result.exit_code, 0)
-		with self.app.test_request_context():
-			self.assertNotIn(self.get_access_group(), self.get_user().groups)
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 3d42f4437c4307d8f7c38829fe6c515513418ae5..61dca8630e4c99d884bdadc1401eb4f55a6a1fb0 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -1,6 +1,6 @@
 from uffd.utils import nopad_b32decode, nopad_b32encode, nopad_urlsafe_b64decode, nopad_urlsafe_b64encode
 
-from utils import UffdTestCase
+from tests.utils import UffdTestCase
 
 class TestUtils(UffdTestCase):
 	def test_nopad_b32(self):
diff --git a/tests/utils.py b/tests/utils.py
index 3b98e64b9d8ac3dba8968338ccdd450ab3cc2ee2..afea2e0ff8841b16ac278a8caa7f8c3cb5201c16 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -1,9 +1,7 @@
 import os
-import tempfile
-import shutil
 import unittest
 
-from flask import request, url_for
+from flask import url_for
 
 from uffd import create_app, db
 from uffd.models import User, Group, Mail
diff --git a/tests/views/__init__.py b/tests/views/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/tests/test_api.py b/tests/views/test_api.py
similarity index 99%
rename from tests/test_api.py
rename to tests/views/test_api.py
index 678aa467e8d3b68c7327384ba3a10de04f906c72..fce8554b4095cbe8ab1779bb59b04cf956fa7d9f 100644
--- a/tests/test_api.py
+++ b/tests/views/test_api.py
@@ -7,7 +7,7 @@ from uffd.remailer import remailer
 from uffd.database import db
 from uffd.models import APIClient, Service, User, RemailerMode
 from uffd.views.api import apikey_required
-from utils import UffdTestCase, db_flush
+from tests.utils import UffdTestCase, db_flush
 
 def basic_auth(username, password):
 	return ('Authorization', 'Basic ' + base64.b64encode(f'{username}:{password}'.encode()).decode())
diff --git a/tests/test_invite.py b/tests/views/test_invite.py
similarity index 68%
rename from tests/test_invite.py
rename to tests/views/test_invite.py
index d9bc44dbac1263096aea8036a6a22e428eb45fa2..13d270877dd15b37e2e6c0f9ce721c57a554d845 100644
--- a/tests/test_invite.py
+++ b/tests/views/test_invite.py
@@ -1,272 +1,11 @@
-import unittest
 import datetime
-import time
 
-from flask import url_for, session, current_app
+from flask import url_for, current_app
 
-from uffd import create_app, db
-from uffd.models import Invite, InviteGrant, InviteSignup, User, Group, Role, RoleGroup
-from uffd.views.session import login_get_user
+from uffd.database import db
+from uffd.models import Invite, InviteGrant, InviteSignup, Role, RoleGroup
 
-from utils import dump, UffdTestCase, db_flush
-
-class TestInviteModel(UffdTestCase):
-	def test_expire(self):
-		invite = Invite(valid_until=datetime.datetime.utcnow() + datetime.timedelta(seconds=60), creator=self.get_admin())
-		self.assertFalse(invite.expired)
-		self.assertTrue(invite.active)
-		invite.valid_until = datetime.datetime.utcnow() - datetime.timedelta(seconds=60)
-		self.assertTrue(invite.expired)
-		self.assertFalse(invite.active)
-
-	def test_void(self):
-		invite = Invite(valid_until=datetime.datetime.utcnow() + datetime.timedelta(seconds=60), single_use=False, creator=self.get_admin())
-		self.assertFalse(invite.voided)
-		self.assertTrue(invite.active)
-		invite.used = True
-		self.assertFalse(invite.voided)
-		self.assertTrue(invite.active)
-		invite = Invite(valid_until=datetime.datetime.utcnow() + datetime.timedelta(seconds=60), single_use=True, creator=self.get_admin())
-		self.assertFalse(invite.voided)
-		self.assertTrue(invite.active)
-		invite.used = True
-		self.assertTrue(invite.voided)
-		self.assertFalse(invite.active)
-
-	def test_permitted(self):
-		role = Role(name='testrole')
-		invite = Invite(valid_until=datetime.datetime.utcnow() + datetime.timedelta(seconds=60), allow_signup=True, roles=[role])
-		self.assertFalse(invite.permitted)
-		self.assertFalse(invite.active)
-		invite.creator = self.get_admin()
-		self.assertTrue(invite.permitted)
-		self.assertTrue(invite.active)
-		invite.creator = self.get_user()
-		self.assertFalse(invite.permitted)
-		self.assertFalse(invite.active)
-		role.moderator_group = self.get_access_group()
-		current_app.config['ACL_SIGNUP_GROUP'] = 'uffd_access'
-		self.assertTrue(invite.permitted)
-		self.assertTrue(invite.active)
-		role.moderator_group = None
-		self.assertFalse(invite.permitted)
-		self.assertFalse(invite.active)
-		role.moderator_group = self.get_access_group()
-		current_app.config['ACL_SIGNUP_GROUP'] = 'uffd_admin'
-		self.assertFalse(invite.permitted)
-		self.assertFalse(invite.active)
-
-	def test_disable(self):
-		invite = Invite(valid_until=datetime.datetime.utcnow() + datetime.timedelta(seconds=60), creator=self.get_admin())
-		self.assertTrue(invite.active)
-		invite.disable()
-		self.assertFalse(invite.active)
-
-	def test_reset_disabled(self):
-		invite = Invite(valid_until=datetime.datetime.utcnow() + datetime.timedelta(seconds=60), creator=self.get_admin())
-		invite.disable()
-		self.assertFalse(invite.active)
-		invite.reset()
-		self.assertTrue(invite.active)
-
-	def test_reset_expired(self):
-		invite = Invite(valid_until=datetime.datetime.utcnow() - datetime.timedelta(seconds=60), creator=self.get_admin())
-		self.assertFalse(invite.active)
-		invite.reset()
-		self.assertFalse(invite.active)
-
-	def test_reset_single_use(self):
-		invite = Invite(valid_until=datetime.datetime.utcnow() + datetime.timedelta(seconds=60), single_use=False, creator=self.get_admin())
-		invite.used = True
-		invite.disable()
-		self.assertFalse(invite.active)
-		invite.reset()
-		self.assertTrue(invite.active)
-
-	def test_short_token(self):
-		invite = Invite(valid_until=datetime.datetime.utcnow() + datetime.timedelta(seconds=60), creator=self.get_admin())
-		db.session.add(invite)
-		db.session.commit()
-		self.assertTrue(len(invite.short_token) <= len(invite.token)/3)
-
-class TestInviteGrantModel(UffdTestCase):
-	def test_success(self):
-		user = self.get_user()
-		group0 = self.get_access_group()
-		role0 = Role(name='baserole', groups={group0: RoleGroup(group=group0)})
-		db.session.add(role0)
-		user.roles.append(role0)
-		user.update_groups()
-		group1 = self.get_admin_group()
-		role1 = Role(name='testrole1', groups={group1: RoleGroup(group=group1)})
-		db.session.add(role1)
-		role2 = Role(name='testrole2')
-		db.session.add(role2)
-		invite = Invite(valid_until=datetime.datetime.utcnow() + datetime.timedelta(seconds=60), roles=[role1, role2], creator=self.get_admin())
-		self.assertIn(role0, user.roles)
-		self.assertNotIn(role1, user.roles)
-		self.assertNotIn(role2, user.roles)
-		self.assertIn(group0, user.groups)
-		self.assertNotIn(group1, user.groups)
-		self.assertFalse(invite.used)
-		grant = InviteGrant(invite=invite, user=user)
-		success, msg = grant.apply()
-		self.assertTrue(success)
-		self.assertIn(role0, user.roles)
-		self.assertIn(role1, user.roles)
-		self.assertIn(role2, user.roles)
-		self.assertIn(group0, user.groups)
-		self.assertIn(group1, user.groups)
-		self.assertTrue(invite.used)
-		db.session.commit()
-		db_flush()
-		user = self.get_user()
-		self.assertIn('baserole', [role.name for role in user.roles_effective])
-		self.assertIn('testrole1', [role.name for role in user.roles])
-		self.assertIn('testrole2', [role.name for role in user.roles])
-		self.assertIn(self.get_access_group(), user.groups)
-		self.assertIn(self.get_admin_group(), user.groups)
-
-	def test_inactive(self):
-		user = self.get_user()
-		group = self.get_admin_group()
-		role = Role(name='testrole1', groups={group: RoleGroup(group=group)})
-		db.session.add(role)
-		invite = Invite(valid_until=datetime.datetime.utcnow() + datetime.timedelta(seconds=60), roles=[role], single_use=True, used=True, creator=self.get_admin())
-		self.assertFalse(invite.active)
-		grant = InviteGrant(invite=invite, user=user)
-		success, msg = grant.apply()
-		self.assertFalse(success)
-		self.assertIsInstance(msg, str)
-		self.assertNotIn(role, user.roles)
-		self.assertNotIn(group, user.groups)
-
-	def test_no_roles(self):
-		user = self.get_user()
-		invite = Invite(valid_until=datetime.datetime.utcnow() + datetime.timedelta(seconds=60), creator=self.get_admin())
-		self.assertTrue(invite.active)
-		grant = InviteGrant(invite=invite, user=user)
-		success, msg = grant.apply()
-		self.assertFalse(success)
-		self.assertIsInstance(msg, str)
-
-	def test_no_new_roles(self):
-		user = self.get_user()
-		role = Role(name='testrole1')
-		db.session.add(role)
-		user.roles.append(role)
-		invite = Invite(valid_until=datetime.datetime.utcnow() + datetime.timedelta(seconds=60), roles=[role], creator=self.get_admin())
-		self.assertTrue(invite.active)
-		grant = InviteGrant(invite=invite, user=user)
-		success, msg = grant.apply()
-		self.assertFalse(success)
-		self.assertIsInstance(msg, str)
-
-class TestInviteSignupModel(UffdTestCase):
-	def create_base_roles(self):
-		baserole = Role(name='base', is_default=True)
-		baserole.groups[self.get_access_group()] = RoleGroup()
-		baserole.groups[self.get_users_group()] = RoleGroup()
-		db.session.add(baserole)
-		db.session.commit()
-
-	def test_success(self):
-		self.create_base_roles()
-		base_role = Role.query.filter_by(name='base').one()
-		base_group1 = self.get_access_group()
-		base_group2 = self.get_users_group()
-		group = self.get_admin_group()
-		role1 = Role(name='testrole1', groups={group: RoleGroup(group=group)})
-		db.session.add(role1)
-		role2 = Role(name='testrole2')
-		db.session.add(role2)
-		invite = Invite(valid_until=datetime.datetime.utcnow() + datetime.timedelta(seconds=60), roles=[role1, role2], allow_signup=True, creator=self.get_admin())
-		signup = InviteSignup(invite=invite, loginname='newuser', displayname='New User', mail='test@example.com', password='notsecret')
-		self.assertFalse(invite.used)
-		valid, msg = signup.validate()
-		self.assertTrue(valid)
-		self.assertFalse(invite.used)
-		user, msg = signup.finish('notsecret')
-		self.assertIsInstance(user, User)
-		self.assertTrue(invite.used)
-		self.assertEqual(user.loginname, 'newuser')
-		self.assertEqual(user.displayname, 'New User')
-		self.assertEqual(user.primary_email.address, 'test@example.com')
-		self.assertEqual(signup.user, user)
-		self.assertIn(base_role, user.roles_effective)
-		self.assertIn(role1, user.roles)
-		self.assertIn(role2, user.roles)
-		self.assertIn(base_group1, user.groups)
-		self.assertIn(base_group2, user.groups)
-		self.assertIn(group, user.groups)
-		db.session.commit()
-		db_flush()
-		self.assertEqual(len(User.query.filter_by(loginname='newuser').all()), 1)
-
-	def test_success_no_roles(self):
-		self.create_base_roles()
-		base_role = Role.query.filter_by(name='base').one()
-		base_group1 = self.get_access_group()
-		base_group2 = self.get_users_group()
-		invite = Invite(valid_until=datetime.datetime.utcnow() + datetime.timedelta(seconds=60), allow_signup=True, creator=self.get_admin())
-		signup = InviteSignup(invite=invite, loginname='newuser', displayname='New User', mail='test@example.com', password='notsecret')
-		self.assertFalse(invite.used)
-		valid, msg = signup.validate()
-		self.assertTrue(valid)
-		self.assertFalse(invite.used)
-		user, msg = signup.finish('notsecret')
-		self.assertIsInstance(user, User)
-		self.assertTrue(invite.used)
-		self.assertEqual(user.loginname, 'newuser')
-		self.assertEqual(user.displayname, 'New User')
-		self.assertEqual(user.primary_email.address, 'test@example.com')
-		self.assertEqual(signup.user, user)
-		self.assertIn(base_role, user.roles_effective)
-		self.assertEqual(len(user.roles_effective), 1)
-		self.assertIn(base_group1, user.groups)
-		self.assertIn(base_group2, user.groups)
-		self.assertEqual(len(user.groups), 2)
-		db.session.commit()
-		db_flush()
-		self.assertEqual(len(User.query.filter_by(loginname='newuser').all()), 1)
-
-	def test_inactive(self):
-		invite = Invite(valid_until=datetime.datetime.utcnow() + datetime.timedelta(seconds=60), allow_signup=True, single_use=True, used=True, creator=self.get_admin())
-		self.assertFalse(invite.active)
-		signup = InviteSignup(invite=invite, loginname='newuser', displayname='New User', mail='test@example.com', password='notsecret')
-		valid, msg = signup.validate()
-		self.assertFalse(valid)
-		self.assertIsInstance(msg, str)
-		user, msg = signup.finish('notsecret')
-		self.assertIsNone(user)
-		self.assertIsInstance(msg, str)
-
-	def test_invalid(self):
-		invite = Invite(valid_until=datetime.datetime.utcnow() + datetime.timedelta(seconds=60), allow_signup=True, creator=self.get_admin())
-		self.assertTrue(invite.active)
-		signup = InviteSignup(invite=invite, loginname='', displayname='New User', mail='test@example.com', password='notsecret')
-		valid, msg = signup.validate()
-		self.assertFalse(valid)
-		self.assertIsInstance(msg, str)
-
-	def test_invalid2(self):
-		invite = Invite(valid_until=datetime.datetime.utcnow() + datetime.timedelta(seconds=60), allow_signup=True, creator=self.get_admin())
-		self.assertTrue(invite.active)
-		signup = InviteSignup(invite=invite, loginname='newuser', displayname='New User', mail='test@example.com', password='notsecret')
-		user, msg = signup.finish('wrongpassword')
-		self.assertIsNone(user)
-		self.assertIsInstance(msg, str)
-
-	def test_no_signup(self):
-		invite = Invite(valid_until=datetime.datetime.utcnow() + datetime.timedelta(seconds=60), allow_signup=False, creator=self.get_admin())
-		self.assertTrue(invite.active)
-		signup = InviteSignup(invite=invite, loginname='newuser', displayname='New User', mail='test@example.com', password='notsecret')
-		valid, msg = signup.validate()
-		self.assertFalse(valid)
-		self.assertIsInstance(msg, str)
-		user, msg = signup.finish('notsecret')
-		self.assertIsNone(user)
-		self.assertIsInstance(msg, str)
+from tests.utils import dump, UffdTestCase, db_flush
 
 class TestInviteAdminViews(UffdTestCase):
 	def setUpApp(self):
diff --git a/tests/test_mail.py b/tests/views/test_mail.py
similarity index 95%
rename from tests/test_mail.py
rename to tests/views/test_mail.py
index 6660b3be3b43e52c48aa48d7ca5ae7e5c1d7a423..638bb949ca943c8da576d5b1b443f1def03eef58 100644
--- a/tests/test_mail.py
+++ b/tests/views/test_mail.py
@@ -1,13 +1,11 @@
-import datetime
-import time
 import unittest
 
-from flask import url_for, session
+from flask import url_for
 
-from uffd import create_app, db
+from uffd.database import db
 from uffd.models import Mail
 
-from utils import dump, UffdTestCase, db_flush
+from tests.utils import dump, UffdTestCase
 
 class TestMailViews(UffdTestCase):
 	def setUp(self):
diff --git a/tests/test_mfa.py b/tests/views/test_mfa.py
similarity index 79%
rename from tests/test_mfa.py
rename to tests/views/test_mfa.py
index f1e254a3bcebe483c7a163edc4d187f4c006db94..589bf3707bc782505bdb324822c7e504b1da7d4f 100644
--- a/tests/test_mfa.py
+++ b/tests/views/test_mfa.py
@@ -1,24 +1,12 @@
-import unittest
-import datetime
 import time
 
 from flask import url_for, session, request
 
-from uffd import create_app, db
-from uffd.models import User, Role, RoleGroup, MFAMethod, MFAType, RecoveryCodeMethod, TOTPMethod, WebauthnMethod
+from uffd.database import db
+from uffd.models import Role, RoleGroup, MFAMethod, RecoveryCodeMethod, TOTPMethod, WebauthnMethod
 from uffd.models.mfa import _hotp
 
-from utils import dump, UffdTestCase, db_flush
-
-class TestMfaPrimitives(unittest.TestCase):
-	def test_hotp(self):
-		self.assertEqual(_hotp(5555555, b'\xae\xa3T\x05\x89\xd6\xb76\xf61r\x92\xcc\xb5WZ\xe6)\x05q'), '458290')
-		self.assertEqual(_hotp(5555555, b'\xae\xa3T\x05\x89\xd6\xb76\xf61r\x92\xcc\xb5WZ\xe6)\x05q', digits=8), '20458290')
-		for digits in range(1, 10):
-			self.assertEqual(len(_hotp(1, b'abcd', digits=digits)), digits)
-		self.assertEqual(_hotp(1234, b''), '161024')
-		self.assertEqual(_hotp(0, b'\x04\x8fM\xcc\x7f\x82\x9c$a\x1b\xb3'), '279354')
-		self.assertEqual(_hotp(2**64-1, b'abcde'), '899292')
+from tests.utils import dump, UffdTestCase, db_flush
 
 def get_fido2_test_cred(self):
 	try:
@@ -28,78 +16,6 @@ def get_fido2_test_cred(self):
 	# Example public key from webauthn spec 6.5.1.1
 	return AttestedCredentialData(bytes.fromhex('00000000000000000000000000000000'+'0040'+'053cbcc9d37a61d3bac87cdcc77ee326256def08ab15775d3a720332e4101d14fae95aeee3bc9698781812e143c0597dc6e180595683d501891e9dd030454c0a'+'A501020326200121582065eda5a12577c2bae829437fe338701a10aaa375e1bb5b5de108de439c08551d2258201e52ed75701163f7f9e40ddf9f341b3dc9ba860af7e0ca7ca7e9eecd0084d19c'))
 
-class TestMfaMethodModels(UffdTestCase):
-	def test_common_attributes(self):
-		method = TOTPMethod(user=self.get_user(), name='testname')
-		self.assertTrue(method.created <= datetime.datetime.utcnow())
-		self.assertEqual(method.name, 'testname')
-		self.assertEqual(method.user.loginname, 'testuser')
-		method.user = self.get_admin()
-		self.assertEqual(method.user.loginname, 'testadmin')
-
-	def test_recovery_code_method(self):
-		method = RecoveryCodeMethod(user=self.get_user())
-		db.session.add(method)
-		db.session.commit()
-		method_id = method.id
-		method_code = method.code
-		db.session.expunge(method)
-		method = RecoveryCodeMethod.query.get(method_id)
-		self.assertFalse(hasattr(method, 'code'))
-		self.assertFalse(method.verify(''))
-		self.assertFalse(method.verify('A'*8))
-		self.assertTrue(method.verify(method_code))
-
-	def test_totp_method_attributes(self):
-		method = TOTPMethod(user=self.get_user(), name='testname')
-		raw_key = method.raw_key
-		issuer = method.issuer
-		accountname = method.accountname
-		key_uri = method.key_uri
-		self.assertEqual(method.name, 'testname')
-		# Restore method with key parameter
-		_method = TOTPMethod(user=self.get_user(), key=method.key, name='testname')
-		self.assertEqual(_method.name, 'testname')
-		self.assertEqual(_method.raw_key, raw_key)
-		self.assertEqual(_method.issuer, issuer)
-		self.assertEqual(_method.accountname, accountname)
-		self.assertEqual(_method.key_uri, key_uri)
-		db.session.add(method)
-		db.session.commit()
-		_method_id = _method.id
-		db.session.expunge(_method)
-		# Restore method from db
-		_method = TOTPMethod.query.get(_method_id)
-		self.assertEqual(_method.name, 'testname')
-		self.assertEqual(_method.raw_key, raw_key)
-		self.assertEqual(_method.issuer, issuer)
-		self.assertEqual(_method.accountname, accountname)
-		self.assertEqual(_method.key_uri, key_uri)
-
-	def test_totp_method_verify(self):
-		method = TOTPMethod(user=self.get_user())
-		counter = int(time.time()/30)
-		self.assertFalse(method.verify(''))
-		self.assertFalse(method.verify(_hotp(counter-2, method.raw_key)))
-		self.assertTrue(method.verify(_hotp(counter, method.raw_key)))
-		self.assertFalse(method.verify(_hotp(counter+2, method.raw_key)))
-
-	def test_webauthn_method(self):
-		data = get_fido2_test_cred(self)
-		method = WebauthnMethod(user=self.get_user(), cred=data, name='testname')
-		self.assertEqual(method.name, 'testname')
-		db.session.add(method)
-		db.session.commit()
-		method_id = method.id
-		method_cred = method.cred
-		db.session.expunge(method)
-		_method = WebauthnMethod.query.get(method_id)
-		self.assertEqual(_method.name, 'testname')
-		self.assertEqual(bytes(method_cred), bytes(_method.cred))
-		self.assertEqual(data.credential_id, _method.cred.credential_id)
-		self.assertEqual(data.public_key, _method.cred.public_key)
-		# We only test (de-)serialization here, as everything else is currently implemented in the views
-
 class TestMfaViews(UffdTestCase):
 	def setUp(self):
 		super().setUp()
diff --git a/tests/test_oauth2.py b/tests/views/test_oauth2.py
similarity index 98%
rename from tests/test_oauth2.py
rename to tests/views/test_oauth2.py
index e47ba99593a7fea1e89d55897730af28410192ac..72c9ff7f6d2b7a0cc0e898109dc46fe988fd3a3e 100644
--- a/tests/test_oauth2.py
+++ b/tests/views/test_oauth2.py
@@ -1,14 +1,13 @@
-import datetime
 from urllib.parse import urlparse, parse_qs
 
 from flask import url_for, session
 
-from uffd import create_app, db
+from uffd.database import db
 from uffd.password_hash import PlaintextPasswordHash
 from uffd.remailer import remailer
-from uffd.models import User, DeviceLoginConfirmation, Service, OAuth2Client, OAuth2DeviceLoginInitiation, RemailerMode
+from uffd.models import DeviceLoginConfirmation, Service, OAuth2Client, OAuth2DeviceLoginInitiation, RemailerMode
 
-from utils import dump, UffdTestCase
+from tests.utils import dump, UffdTestCase
 
 class TestViews(UffdTestCase):
 	def setUpDB(self):
diff --git a/tests/views/test_role.py b/tests/views/test_role.py
new file mode 100644
index 0000000000000000000000000000000000000000..befe12af068e0d2254c1c2c2548f09e31bd7231b
--- /dev/null
+++ b/tests/views/test_role.py
@@ -0,0 +1,153 @@
+from flask import url_for
+
+from uffd.database import db
+from uffd.models import User, Role, RoleGroup
+
+from tests.utils import dump, UffdTestCase
+
+class TestRoleViews(UffdTestCase):
+	def setUp(self):
+		super().setUp()
+		self.login_as('admin')
+
+	def test_index(self):
+		db.session.add(Role(name='base', description='Base role description'))
+		db.session.add(Role(name='test1', description='Test1 role description'))
+		db.session.commit()
+		r = self.client.get(path=url_for('role.index'), follow_redirects=True)
+		dump('role_index', r)
+		self.assertEqual(r.status_code, 200)
+
+	def test_index_empty(self):
+		r = self.client.get(path=url_for('role.index'), follow_redirects=True)
+		dump('role_index_empty', r)
+		self.assertEqual(r.status_code, 200)
+
+	def test_show(self):
+		role = Role(name='base', description='Base role description')
+		db.session.add(role)
+		db.session.commit()
+		r = self.client.get(path=url_for('role.show', roleid=role.id), follow_redirects=True)
+		dump('role_show', r)
+		self.assertEqual(r.status_code, 200)
+
+	def test_new(self):
+		r = self.client.get(path=url_for('role.new'), follow_redirects=True)
+		dump('role_new', r)
+		self.assertEqual(r.status_code, 200)
+
+	def test_update(self):
+		role = Role(name='base', description='Base role description')
+		db.session.add(role)
+		db.session.commit()
+		role.groups[self.get_admin_group()] = RoleGroup()
+		db.session.commit()
+		self.assertEqual(role.name, 'base')
+		self.assertEqual(role.description, 'Base role description')
+		self.assertSetEqual(set(role.groups), {self.get_admin_group()})
+		r = self.client.post(path=url_for('role.update', roleid=role.id),
+			data={'name': 'base1', 'description': 'Base role description1', 'moderator-group': '', 'group-%d'%self.get_users_group().id: '1', 'group-%d'%self.get_access_group().id: '1'},
+			follow_redirects=True)
+		dump('role_update', r)
+		self.assertEqual(r.status_code, 200)
+		role = Role.query.get(role.id)
+		self.assertEqual(role.name, 'base1')
+		self.assertEqual(role.description, 'Base role description1')
+		self.assertSetEqual(set(role.groups), {self.get_access_group(), self.get_users_group()})
+		# TODO: verify that group memberships are updated
+
+	def test_create(self):
+		self.assertIsNone(Role.query.filter_by(name='base').first())
+		r = self.client.post(path=url_for('role.update'),
+			data={'name': 'base', 'description': 'Base role description', 'moderator-group': '', 'group-%d'%self.get_users_group().id: '1', 'group-%d'%self.get_access_group().id: '1'},
+			follow_redirects=True)
+		dump('role_create', r)
+		self.assertEqual(r.status_code, 200)
+		role = Role.query.filter_by(name='base').first()
+		self.assertIsNotNone(role)
+		self.assertEqual(role.name, 'base')
+		self.assertEqual(role.description, 'Base role description')
+		self.assertSetEqual(set(role.groups), {self.get_access_group(), self.get_users_group()})
+		# TODO: verify that group memberships are updated (currently not possible with ldap mock!)
+
+	def test_create_with_moderator_group(self):
+		self.assertIsNone(Role.query.filter_by(name='base').first())
+		r = self.client.post(path=url_for('role.update'),
+			data={'name': 'base', 'description': 'Base role description', 'moderator-group': self.get_admin_group().id, 'group-%d'%self.get_users_group().id: '1', 'group-%d'%self.get_access_group().id: '1'},
+			follow_redirects=True)
+		self.assertEqual(r.status_code, 200)
+		role = Role.query.filter_by(name='base').first()
+		self.assertIsNotNone(role)
+		self.assertEqual(role.name, 'base')
+		self.assertEqual(role.description, 'Base role description')
+		self.assertEqual(role.moderator_group.name, 'uffd_admin')
+		self.assertSetEqual(set(role.groups), {self.get_access_group(), self.get_users_group()})
+		# TODO: verify that group memberships are updated (currently not possible with ldap mock!)
+
+	def test_delete(self):
+		role = Role(name='base', description='Base role description')
+		db.session.add(role)
+		db.session.commit()
+		role_id = role.id
+		self.assertIsNotNone(Role.query.get(role_id))
+		r = self.client.get(path=url_for('role.delete', roleid=role.id), follow_redirects=True)
+		dump('role_delete', r)
+		self.assertEqual(r.status_code, 200)
+		self.assertIsNone(Role.query.get(role_id))
+		# TODO: verify that group memberships are updated (currently not possible with ldap mock!)
+
+	def test_set_default(self):
+		db.session.add(User(loginname='service', is_service_user=True, primary_email_address='service@example.com', displayname='Service'))
+		db.session.commit()
+		role = Role(name='test')
+		db.session.add(role)
+		role.groups[self.get_admin_group()] = RoleGroup()
+		user1 = self.get_user()
+		user2 = self.get_admin()
+		service_user = User.query.filter_by(loginname='service').one_or_none()
+		self.assertSetEqual(set(self.get_user().roles_effective), set())
+		self.assertSetEqual(set(self.get_admin().roles_effective), set())
+		self.assertSetEqual(set(service_user.roles_effective), set())
+		role.members.append(self.get_user())
+		role.members.append(service_user)
+		self.assertSetEqual(set(self.get_user().roles_effective), {role})
+		self.assertSetEqual(set(self.get_admin().roles_effective), set())
+		self.assertSetEqual(set(service_user.roles_effective), {role})
+		db.session.commit()
+		role_id = role.id
+		self.assertSetEqual(set(role.members), {self.get_user(), service_user})
+		r = self.client.get(path=url_for('role.set_default', roleid=role.id), follow_redirects=True)
+		dump('role_set_default', r)
+		self.assertEqual(r.status_code, 200)
+		role = Role.query.get(role_id)
+		service_user = User.query.filter_by(loginname='service').one_or_none()
+		self.assertSetEqual(set(role.members), {service_user})
+		self.assertSetEqual(set(self.get_user().roles_effective), {role})
+		self.assertSetEqual(set(self.get_admin().roles_effective), {role})
+
+	def test_unset_default(self):
+		admin_role = Role(name='admin', is_default=True)
+		db.session.add(admin_role)
+		admin_role.groups[self.get_admin_group()] = RoleGroup()
+		db.session.add(User(loginname='service', is_service_user=True, primary_email_address='service@example.com', displayname='Service'))
+		db.session.commit()
+		role = Role(name='test', is_default=True)
+		db.session.add(role)
+		service_user = User.query.filter_by(loginname='service').one_or_none()
+		role.members.append(service_user)
+		self.assertSetEqual(set(self.get_user().roles_effective), {role, admin_role})
+		self.assertSetEqual(set(self.get_admin().roles_effective), {role, admin_role})
+		self.assertSetEqual(set(service_user.roles_effective), {role})
+		db.session.commit()
+		role_id = role.id
+		admin_role_id = admin_role.id
+		self.assertSetEqual(set(role.members), {service_user})
+		r = self.client.get(path=url_for('role.unset_default', roleid=role.id), follow_redirects=True)
+		dump('role_unset_default', r)
+		self.assertEqual(r.status_code, 200)
+		role = Role.query.get(role_id)
+		admin_role = Role.query.get(admin_role_id)
+		service_user = User.query.filter_by(loginname='service').one_or_none()
+		self.assertSetEqual(set(role.members), {service_user})
+		self.assertSetEqual(set(self.get_user().roles_effective), {admin_role})
+		self.assertSetEqual(set(self.get_admin().roles_effective), {admin_role})
diff --git a/tests/test_rolemod.py b/tests/views/test_rolemod.py
similarity index 98%
rename from tests/test_rolemod.py
rename to tests/views/test_rolemod.py
index 2950c83c43dba8801a524969e725397dff551d76..1f74fe9b2890768438ed97f1c3f0aa76380383bd 100644
--- a/tests/test_rolemod.py
+++ b/tests/views/test_rolemod.py
@@ -1,9 +1,9 @@
 from flask import url_for
 
 from uffd.database import db
-from uffd.models import User, Group, Role, RoleGroup
+from uffd.models import Role, RoleGroup
 
-from utils import dump, UffdTestCase
+from tests.utils import dump, UffdTestCase
 
 class TestRolemodViewsLoggedOut(UffdTestCase):
 	def test_acl_nologin(self):
diff --git a/tests/test_selfservice.py b/tests/views/test_selfservice.py
similarity index 99%
rename from tests/test_selfservice.py
rename to tests/views/test_selfservice.py
index 6f686f4bb80643b908fe615eb07fe4f7f717eb33..8260c04c8db1bb5227543678db3de63f321a15e1 100644
--- a/tests/test_selfservice.py
+++ b/tests/views/test_selfservice.py
@@ -1,13 +1,12 @@
 import datetime
 import re
-import unittest
 
 from flask import url_for, request
 
-from uffd import create_app, db
-from uffd.models import PasswordToken, User, UserEmail, Role, RoleGroup, Service, ServiceUser
+from uffd.database import db
+from uffd.models import PasswordToken, UserEmail, Role, RoleGroup, Service, ServiceUser
 
-from utils import dump, UffdTestCase
+from tests.utils import dump, UffdTestCase
 
 class TestSelfservice(UffdTestCase):
 	def test_index(self):
diff --git a/tests/views/test_services.py b/tests/views/test_services.py
new file mode 100644
index 0000000000000000000000000000000000000000..1e57d8be565b242f1a96ba1474e0d2ee3888381e
--- /dev/null
+++ b/tests/views/test_services.py
@@ -0,0 +1,98 @@
+from flask import url_for
+
+from tests.utils import dump, UffdTestCase
+
+class TestServices(UffdTestCase):
+	def setUpApp(self):
+		self.app.config['SERVICES'] = [
+			{
+				'title': 'Service Title',
+				'subtitle': 'Service Subtitle',
+				'description': 'Short description of the service as plain text',
+				'url': 'https://example.com/',
+				'logo_url': '/static/fairy-dust-color.png',
+				'required_group': 'users',
+				'permission_levels': [
+					{'name': 'Moderator', 'required_group': 'moderators'},
+					{'name': 'Admin', 'required_group': 'uffd_admin'},
+				],
+				'confidential': True,
+				'groups': [
+					{'name': 'Group "crew_crew"', 'required_group': 'users'},
+					{'name': 'Group "crew_logistik"', 'required_group': 'uffd_admin'},
+				],
+				'infos': [
+					{'title': 'Documentation', 'html': '<p>Some information about the service as html</p>', 'required_group': 'users'},
+				],
+				'links': [
+					{'title': 'Link to an external site', 'url': '#', 'required_group': 'users'},
+				],
+			},
+			{
+				'title': 'Minimal Service Title',
+			}
+		]
+		self.app.config['SERVICES_PUBLIC'] = True
+
+	def test_overview(self):
+		r = self.client.get(path=url_for('service.overview'))
+		dump('service_overview_guest', r)
+		self.assertEqual(r.status_code, 200)
+		self.assertNotIn(b'https://example.com/', r.data)
+		self.login_as('user')
+		r = self.client.get(path=url_for('service.overview'))
+		dump('service_overview_user', r)
+		self.assertEqual(r.status_code, 200)
+		self.assertIn(b'https://example.com/', r.data)
+
+	def test_overview_disabled(self):
+		self.app.config['SERVICES'] = []
+		# Should return login page
+		r = self.client.get(path=url_for('service.overview'), follow_redirects=True)
+		dump('service_overview_disabled_guest', r)
+		self.assertEqual(r.status_code, 200)
+		self.assertIn(b'name="password"', r.data)
+		self.login_as('user')
+		# Should return access denied page
+		r = self.client.get(path=url_for('service.overview'), follow_redirects=True)
+		dump('service_overview_disabled_user', r)
+		self.assertEqual(r.status_code, 403)
+		self.login_as('admin')
+		# Should return (empty) overview page
+		r = self.client.get(path=url_for('service.overview'), follow_redirects=True)
+		dump('service_overview_disabled_admin', r)
+		self.assertEqual(r.status_code, 200)
+
+	def test_overview_nonpublic(self):
+		self.app.config['SERVICES_PUBLIC'] = False
+		# Should return login page
+		r = self.client.get(path=url_for('service.overview'), follow_redirects=True)
+		dump('service_overview_nonpublic_guest', r)
+		self.assertEqual(r.status_code, 200)
+		self.assertIn(b'name="password"', r.data)
+		self.login_as('user')
+		# Should return overview page
+		r = self.client.get(path=url_for('service.overview'), follow_redirects=True)
+		dump('service_overview_nonpublic_user', r)
+		self.assertEqual(r.status_code, 200)
+		self.login_as('admin')
+		# Should return overview page
+		r = self.client.get(path=url_for('service.overview'), follow_redirects=True)
+		dump('service_overview_nonpublic_admin', r)
+		self.assertEqual(r.status_code, 200)
+
+	def test_overview_public(self):
+		# Should return overview page
+		r = self.client.get(path=url_for('service.overview'), follow_redirects=True)
+		dump('service_overview_public_guest', r)
+		self.assertEqual(r.status_code, 200)
+		self.login_as('user')
+		# Should return overview page
+		r = self.client.get(path=url_for('service.overview'), follow_redirects=True)
+		dump('service_overview_public_user', r)
+		self.assertEqual(r.status_code, 200)
+		self.login_as('admin')
+		# Should return overview page
+		r = self.client.get(path=url_for('service.overview'), follow_redirects=True)
+		dump('service_overview_public_admin', r)
+		self.assertEqual(r.status_code, 200)
diff --git a/tests/test_session.py b/tests/views/test_session.py
similarity index 98%
rename from tests/test_session.py
rename to tests/views/test_session.py
index bad3b887499b6c9169809a39bcc049a921320629..0cb5c340688eed0a6d2eab8b2f67453f6e2268eb 100644
--- a/tests/test_session.py
+++ b/tests/views/test_session.py
@@ -3,12 +3,12 @@ import unittest
 
 from flask import url_for, request
 
-from uffd import create_app, db
+from uffd.database import db
 from uffd.password_hash import PlaintextPasswordHash
 from uffd.models import DeviceLoginConfirmation, Service, OAuth2Client, OAuth2DeviceLoginInitiation, User
 from uffd.views.session import login_required
 
-from utils import dump, UffdTestCase, db_flush
+from tests.utils import dump, UffdTestCase, db_flush
 
 class TestSession(UffdTestCase):
 	def setUpApp(self):
diff --git a/tests/test_signup.py b/tests/views/test_signup.py
similarity index 66%
rename from tests/test_signup.py
rename to tests/views/test_signup.py
index 9affe7f90dab378f1f4aa8c062730d4fe8e3c7b4..8242481e284be17deae83bcc6ac21a19fe75dc26 100644
--- a/tests/test_signup.py
+++ b/tests/views/test_signup.py
@@ -1,14 +1,12 @@
-import unittest
 import datetime
-import time
 
-from flask import url_for, session, request
+from flask import url_for, request
 
-from uffd import create_app, db
-from uffd.models import Signup, User, Role, RoleGroup
+from uffd.database import db
+from uffd.models import Signup, Role, RoleGroup
 from uffd.views.session import login_get_user
 
-from utils import dump, UffdTestCase, db_flush
+from tests.utils import dump, UffdTestCase, db_flush
 
 def refetch_signup(signup):
 	db.session.add(signup)
@@ -17,162 +15,6 @@ def refetch_signup(signup):
 	db.session.expunge(signup)
 	return Signup.query.get(id)
 
-# We assume in all tests that Signup.validate and Signup.password.verify do
-# not alter any state
-
-class TestSignupModel(UffdTestCase):
-	def assert_validate_valid(self, signup):
-		valid, msg = signup.validate()
-		self.assertTrue(valid)
-		self.assertIsInstance(msg, str)
-
-	def assert_validate_invalid(self, signup):
-		valid, msg = signup.validate()
-		self.assertFalse(valid)
-		self.assertIsInstance(msg, str)
-		self.assertNotEqual(msg, '')
-
-	def assert_finish_success(self, signup, password):
-		self.assertIsNone(signup.user)
-		user, msg = signup.finish(password)
-		db.session.commit()
-		self.assertIsNotNone(user)
-		self.assertIsInstance(msg, str)
-		self.assertIsNotNone(signup.user)
-
-	def assert_finish_failure(self, signup, password):
-		prev_id = signup.user_id
-		user, msg = signup.finish(password)
-		self.assertIsNone(user)
-		self.assertIsInstance(msg, str)
-		self.assertNotEqual(msg, '')
-		self.assertEqual(signup.user_id, prev_id)
-
-	def test_password(self):
-		signup = Signup(loginname='newuser', displayname='New User', mail='test@example.com')
-		self.assertFalse(signup.password.verify('notsecret'))
-		self.assertFalse(signup.password.verify(''))
-		self.assertFalse(signup.password.verify('wrongpassword'))
-		self.assertTrue(signup.set_password('notsecret'))
-		self.assertTrue(signup.password.verify('notsecret'))
-		self.assertFalse(signup.password.verify('wrongpassword'))
-
-	def test_expired(self):
-		# TODO: Find a better way to test this!
-		signup = Signup(loginname='newuser', displayname='New User', mail='test@example.com', password='notsecret')
-		self.assertFalse(signup.expired)
-		signup.created = created=datetime.datetime.utcnow() - datetime.timedelta(hours=49)
-		self.assertTrue(signup.expired)
-
-	def test_completed(self):
-		signup = Signup(loginname='newuser', displayname='New User', mail='test@example.com', password='notsecret')
-		self.assertFalse(signup.completed)
-		signup.finish('notsecret')
-		db.session.commit()
-		self.assertTrue(signup.completed)
-		signup = refetch_signup(signup)
-		self.assertTrue(signup.completed)
-
-	def test_validate(self):
-		signup = Signup(loginname='newuser', displayname='New User', mail='test@example.com', password='notsecret')
-		self.assert_validate_valid(signup)
-		self.assert_validate_valid(refetch_signup(signup))
-
-	def test_validate_completed(self):
-		signup = Signup(loginname='newuser', displayname='New User', mail='test@example.com', password='notsecret')
-		self.assert_finish_success(signup, 'notsecret')
-		self.assert_validate_invalid(signup)
-		self.assert_validate_invalid(refetch_signup(signup))
-
-	def test_validate_expired(self):
-		signup = Signup(loginname='newuser', displayname='New User', mail='test@example.com',
-		                password='notsecret', created=datetime.datetime.utcnow()-datetime.timedelta(hours=49))
-		self.assert_validate_invalid(signup)
-		self.assert_validate_invalid(refetch_signup(signup))
-
-	def test_validate_loginname(self):
-		signup = Signup(loginname='', displayname='New User', mail='test@example.com', password='notsecret')
-		self.assert_validate_invalid(signup)
-		self.assert_validate_invalid(refetch_signup(signup))
-
-	def test_validate_displayname(self):
-		signup = Signup(loginname='newuser', displayname='', mail='test@example.com', password='notsecret')
-		self.assert_validate_invalid(signup)
-		self.assert_validate_invalid(refetch_signup(signup))
-
-	def test_validate_mail(self):
-		signup = Signup(loginname='newuser', displayname='New User', mail='', password='notsecret')
-		self.assert_validate_invalid(signup)
-		self.assert_validate_invalid(refetch_signup(signup))
-
-	def test_validate_password(self):
-		signup = Signup(loginname='newuser', displayname='New User', mail='test@example.com')
-		self.assertFalse(signup.set_password(''))
-		self.assert_validate_invalid(signup)
-		self.assert_validate_invalid(refetch_signup(signup))
-
-	def test_validate_exists(self):
-		signup = Signup(loginname='testuser', displayname='New User', mail='test@example.com', password='notsecret')
-		self.assert_validate_invalid(signup)
-		self.assert_validate_invalid(refetch_signup(signup))
-
-	def test_finish(self):
-		signup = Signup(loginname='newuser', displayname='New User', mail='test@example.com', password='notsecret')
-		self.assert_finish_success(signup, 'notsecret')
-		user = User.query.filter_by(loginname='newuser').one_or_none()
-		self.assertEqual(user.loginname, 'newuser')
-		self.assertEqual(user.displayname, 'New User')
-		self.assertEqual(user.primary_email.address, 'test@example.com')
-
-	def test_finish_completed(self):
-		signup = Signup(loginname='newuser', displayname='New User', mail='test@example.com', password='notsecret')
-		self.assert_finish_success(signup, 'notsecret')
-		self.assert_finish_failure(refetch_signup(signup), 'notsecret')
-
-	def test_finish_expired(self):
-		# TODO: Find a better way to test this!
-		signup = Signup(loginname='newuser', displayname='New User', mail='test@example.com',
-		                password='notsecret', created=datetime.datetime.utcnow()-datetime.timedelta(hours=49))
-		self.assert_finish_failure(signup, 'notsecret')
-		self.assert_finish_failure(refetch_signup(signup), 'notsecret')
-
-	def test_finish_wrongpassword(self):
-		signup = Signup(loginname='newuser', displayname='New User', mail='test@example.com')
-		self.assert_finish_failure(signup, '')
-		self.assert_finish_failure(signup, 'wrongpassword')
-		signup = refetch_signup(signup)
-		self.assert_finish_failure(signup, '')
-		self.assert_finish_failure(signup, 'wrongpassword')
-		signup = Signup(loginname='newuser', displayname='New User', mail='test@example.com', password='notsecret')
-		self.assert_finish_failure(signup, 'wrongpassword')
-		self.assert_finish_failure(refetch_signup(signup), 'wrongpassword')
-
-	def test_finish_duplicate(self):
-		signup = Signup(loginname='testuser', displayname='New User', mail='test@example.com', password='notsecret')
-		self.assert_finish_failure(signup, 'notsecret')
-		self.assert_finish_failure(refetch_signup(signup), 'notsecret')
-
-	def test_duplicate(self):
-		signup = Signup(loginname='newuser', displayname='New User', mail='test1@example.com', password='notsecret')
-		self.assert_validate_valid(signup)
-		db.session.add(signup)
-		db.session.commit()
-		signup1_id = signup.id
-		signup = Signup(loginname='newuser', displayname='New User', mail='test2@example.com', password='notsecret')
-		self.assert_validate_valid(signup)
-		db.session.add(signup)
-		db.session.commit()
-		signup2_id = signup.id
-		db_flush()
-		signup = Signup.query.get(signup2_id)
-		self.assert_finish_success(signup, 'notsecret')
-		db.session.commit()
-		db_flush()
-		signup = Signup.query.get(signup1_id)
-		self.assert_finish_failure(signup, 'notsecret')
-		user = User.query.filter_by(loginname='newuser').one_or_none()
-		self.assertEqual(user.primary_email.address, 'test2@example.com')
-
 class TestSignupViews(UffdTestCase):
 	def setUpApp(self):
 		self.app.config['SELF_SIGNUP'] = True
diff --git a/tests/test_user.py b/tests/views/test_user.py
similarity index 56%
rename from tests/test_user.py
rename to tests/views/test_user.py
index 81f3a292eb0c4fabd116fe3b092690481fb14128..308167bfce88e01737d211aba18f311a58e10870 100644
--- a/tests/test_user.py
+++ b/tests/views/test_user.py
@@ -1,157 +1,9 @@
-import datetime
-import unittest
-
-from flask import url_for, session
-import sqlalchemy
-
-from uffd import create_app, db
-from uffd.remailer import remailer
-from uffd.models import User, UserEmail, Group, Role, RoleGroup, Service, ServiceUser
-
-from utils import dump, UffdTestCase
-
-class TestUserModel(UffdTestCase):
-	def test_has_permission(self):
-		user_ = self.get_user() # has 'users' and 'uffd_access' group
-		admin = self.get_admin() # has 'users', 'uffd_access' and 'uffd_admin' group
-		self.assertTrue(user_.has_permission(None))
-		self.assertTrue(admin.has_permission(None))
-		self.assertTrue(user_.has_permission('users'))
-		self.assertTrue(admin.has_permission('users'))
-		self.assertFalse(user_.has_permission('notagroup'))
-		self.assertFalse(admin.has_permission('notagroup'))
-		self.assertFalse(user_.has_permission('uffd_admin'))
-		self.assertTrue(admin.has_permission('uffd_admin'))
-		self.assertFalse(user_.has_permission(['uffd_admin']))
-		self.assertTrue(admin.has_permission(['uffd_admin']))
-		self.assertFalse(user_.has_permission(['uffd_admin', 'notagroup']))
-		self.assertTrue(admin.has_permission(['uffd_admin', 'notagroup']))
-		self.assertFalse(user_.has_permission(['notagroup', 'uffd_admin']))
-		self.assertTrue(admin.has_permission(['notagroup', 'uffd_admin']))
-		self.assertTrue(user_.has_permission(['uffd_admin', 'users']))
-		self.assertTrue(admin.has_permission(['uffd_admin', 'users']))
-		self.assertTrue(user_.has_permission([['uffd_admin', 'users'], ['users', 'uffd_access']]))
-		self.assertTrue(admin.has_permission([['uffd_admin', 'users'], ['users', 'uffd_access']]))
-		self.assertFalse(user_.has_permission(['uffd_admin', ['users', 'notagroup']]))
-		self.assertTrue(admin.has_permission(['uffd_admin', ['users', 'notagroup']]))
-
-	def test_unix_uid_generation(self):
-		self.app.config['USER_MIN_UID'] = 10000
-		self.app.config['USER_MAX_UID'] = 18999
-		self.app.config['USER_SERVICE_MIN_UID'] = 19000
-		self.app.config['USER_SERVICE_MAX_UID'] =19999
-		User.query.delete()
-		db.session.commit()
-		user0 = User(loginname='user0', displayname='user0', primary_email_address='user0@example.com')
-		user1 = User(loginname='user1', displayname='user1', primary_email_address='user1@example.com')
-		user2 = User(loginname='user2', displayname='user2', primary_email_address='user2@example.com')
-		db.session.add_all([user0, user1, user2])
-		db.session.commit()
-		self.assertEqual(user0.unix_uid, 10000)
-		self.assertEqual(user1.unix_uid, 10001)
-		self.assertEqual(user2.unix_uid, 10002)
-		db.session.delete(user1)
-		db.session.commit()
-		user3 = User(loginname='user3', displayname='user3', primary_email_address='user3@example.com')
-		db.session.add(user3)
-		db.session.commit()
-		self.assertEqual(user3.unix_uid, 10003)
-		service0 = User(loginname='service0', displayname='service0', primary_email_address='service0@example.com', is_service_user=True)
-		service1 = User(loginname='service1', displayname='service1', primary_email_address='service1@example.com', is_service_user=True)
-		db.session.add_all([service0, service1])
-		db.session.commit()
-		self.assertEqual(service0.unix_uid, 19000)
-		self.assertEqual(service1.unix_uid, 19001)
-
-	def test_unix_uid_generation_overlapping(self):
-		self.app.config['USER_MIN_UID'] = 10000
-		self.app.config['USER_MAX_UID'] = 19999
-		self.app.config['USER_SERVICE_MIN_UID'] = 10000
-		self.app.config['USER_SERVICE_MAX_UID'] = 19999
-		User.query.delete()
-		db.session.commit()
-		user0 = User(loginname='user0', displayname='user0', primary_email_address='user0@example.com')
-		service0 = User(loginname='service0', displayname='service0', primary_email_address='service0@example.com', is_service_user=True)
-		user1 = User(loginname='user1', displayname='user1', primary_email_address='user1@example.com')
-		db.session.add_all([user0, service0, user1])
-		db.session.commit()
-		self.assertEqual(user0.unix_uid, 10000)
-		self.assertEqual(service0.unix_uid, 10001)
-		self.assertEqual(user1.unix_uid, 10002)
-
-	def test_unix_uid_generation_overflow(self):
-		self.app.config['USER_MIN_UID'] = 10000
-		self.app.config['USER_MAX_UID'] = 10001
-		User.query.delete()
-		db.session.commit()
-		user0 = User(loginname='user0', displayname='user0', primary_email_address='user0@example.com')
-		user1 = User(loginname='user1', displayname='user1', primary_email_address='user1@example.com')
-		db.session.add_all([user0, user1])
-		db.session.commit()
-		self.assertEqual(user0.unix_uid, 10000)
-		self.assertEqual(user1.unix_uid, 10001)
-		with self.assertRaises(sqlalchemy.exc.IntegrityError):
-			user2 = User(loginname='user2', displayname='user2', primary_email_address='user2@example.com')
-			db.session.add(user2)
-			db.session.commit()
-
-	def test_init_primary_email_address(self):
-		user = User(primary_email_address='foobar@example.com')
-		self.assertEqual(user.primary_email.address, 'foobar@example.com')
-		self.assertEqual(user.primary_email.verified, True)
-		self.assertEqual(user.primary_email.user, user)
-		user = User(primary_email_address='invalid')
-		self.assertEqual(user.primary_email.address, 'invalid')
-		self.assertEqual(user.primary_email.verified, True)
-		self.assertEqual(user.primary_email.user, user)
-
-	def test_set_primary_email_address(self):
-		user = User()
-		self.assertFalse(user.set_primary_email_address('invalid'))
-		self.assertIsNone(user.primary_email)
-		self.assertEqual(len(user.all_emails), 0)
-		self.assertTrue(user.set_primary_email_address('foobar@example.com'))
-		self.assertEqual(user.primary_email.address, 'foobar@example.com')
-		self.assertEqual(len(user.all_emails), 1)
-		self.assertFalse(user.set_primary_email_address('invalid'))
-		self.assertEqual(user.primary_email.address, 'foobar@example.com')
-		self.assertEqual(len(user.all_emails), 1)
-		self.assertTrue(user.set_primary_email_address('other@example.com'))
-		self.assertEqual(user.primary_email.address, 'other@example.com')
-		self.assertEqual(len(user.all_emails), 2)
-		self.assertEqual({user.all_emails[0].address, user.all_emails[1].address}, {'foobar@example.com', 'other@example.com'})
-
-class TestUserEmailModel(UffdTestCase):
-	def test_set_address(self):
-		email = UserEmail()
-		self.assertFalse(email.set_address('invalid'))
-		self.assertIsNone(email.address)
-		self.assertFalse(email.set_address(''))
-		self.assertFalse(email.set_address('@'))
-		self.app.config['REMAILER_DOMAIN'] = 'remailer.example.com'
-		self.assertFalse(email.set_address('foobar@remailer.example.com'))
-		self.assertFalse(email.set_address('v1-1-testuser@remailer.example.com'))
-		self.assertFalse(email.set_address('v1-1-testuser @ remailer.example.com'))
-		self.assertFalse(email.set_address('v1-1-testuser@REMAILER.example.com'))
-		self.assertFalse(email.set_address('v1-1-testuser@foobar@remailer.example.com'))
-		self.assertTrue(email.set_address('foobar@example.com'))
-		self.assertEqual(email.address, 'foobar@example.com')
-
-	def test_verification(self):
-		email = UserEmail(address='foo@example.com')
-		self.assertFalse(email.finish_verification('test'))
-		secret = email.start_verification()
-		self.assertTrue(email.verification_secret)
-		self.assertTrue(email.verification_secret.verify(secret))
-		self.assertFalse(email.verification_expired)
-		self.assertFalse(email.finish_verification('test'))
-		orig_expires = email.verification_expires
-		email.verification_expires = datetime.datetime.utcnow() - datetime.timedelta(days=1)
-		self.assertFalse(email.finish_verification(secret))
-		email.verification_expires = orig_expires
-		self.assertTrue(email.finish_verification(secret))
-		self.assertFalse(email.verification_secret)
-		self.assertTrue(email.verification_expired)
+from flask import url_for
+
+from uffd.database import db
+from uffd.models import User, UserEmail, Group, Role, Service, ServiceUser
+
+from tests.utils import dump, UffdTestCase
 
 class TestUserViews(UffdTestCase):
 	def setUp(self):
@@ -507,138 +359,6 @@ newuser12,newuser12@example.com,{role1.id};{role1.id}
 		roles = sorted([r.name for r in user.roles])
 		self.assertEqual(roles, ['role1'])
 
-class TestUserCLI(UffdTestCase):
-	def setUp(self):
-		super().setUp()
-		role = Role(name='admin')
-		role.groups[self.get_admin_group()] = RoleGroup(group=self.get_admin_group())
-		db.session.add(role)
-		db.session.add(Role(name='test'))
-		db.session.commit()
-		self.client.__exit__(None, None, None)
-
-	def test_list(self):
-		result = self.app.test_cli_runner().invoke(args=['user', 'list'])
-		self.assertEqual(result.exit_code, 0)
-
-	def test_show(self):
-		result = self.app.test_cli_runner().invoke(args=['user', 'show', 'testuser'])
-		self.assertEqual(result.exit_code, 0)
-		result = self.app.test_cli_runner().invoke(args=['user', 'show', 'doesnotexist'])
-		self.assertEqual(result.exit_code, 1)
-
-	def test_create(self):
-		result = self.app.test_cli_runner().invoke(args=['user', 'create', 'new user', '--mail', 'foobar@example.com']) # invalid login name
-		self.assertEqual(result.exit_code, 1)
-		result = self.app.test_cli_runner().invoke(args=['user', 'create', 'newuser', '--mail', '']) # invalid mail
-		self.assertEqual(result.exit_code, 1)
-		result = self.app.test_cli_runner().invoke(args=['user', 'create', 'newuser', '--mail', 'foobar@example.com', '--password', '']) # invalid password
-		self.assertEqual(result.exit_code, 1)
-		result = self.app.test_cli_runner().invoke(args=['user', 'create', 'newuser', '--mail', 'foobar@example.com', '--displayname', '']) # invalid display name
-		self.assertEqual(result.exit_code, 1)
-		result = self.app.test_cli_runner().invoke(args=['user', 'create', 'newuser', '--mail', 'foobar@example.com', '--add-role', 'doesnotexist']) # unknown role
-		self.assertEqual(result.exit_code, 1)
-		result = self.app.test_cli_runner().invoke(args=['user', 'create', 'testuser', '--mail', 'foobar@example.com']) # conflicting name
-		self.assertEqual(result.exit_code, 1)
-		result = self.app.test_cli_runner().invoke(args=['user', 'create', 'newuser', '--mail', 'newmail@example.com',
-		                                                 '--displayname', 'New Display Name', '--password', 'newpassword', '--add-role', 'admin'])
-		self.assertEqual(result.exit_code, 0)
-		with self.app.test_request_context():
-			user = User.query.filter_by(loginname='newuser').first()
-			self.assertIsNotNone(user)
-			self.assertEqual(user.primary_email.address, 'newmail@example.com')
-			self.assertEqual(user.displayname, 'New Display Name')
-			self.assertTrue(user.password.verify('newpassword'))
-			self.assertEqual(user.roles, Role.query.filter_by(name='admin').all())
-			self.assertIn(self.get_admin_group(), user.groups)
-
-	def test_update(self):
-		result = self.app.test_cli_runner().invoke(args=['user', 'update', 'doesnotexist', '--displayname', 'foo'])
-		self.assertEqual(result.exit_code, 1)
-		result = self.app.test_cli_runner().invoke(args=['user', 'update', 'testuser', '--mail', '']) # invalid mail
-		self.assertEqual(result.exit_code, 1)
-		result = self.app.test_cli_runner().invoke(args=['user', 'update', 'testuser', '--password', '']) # invalid password
-		self.assertEqual(result.exit_code, 1)
-		result = self.app.test_cli_runner().invoke(args=['user', 'update', 'testuser', '--displayname', '']) # invalid display name
-		self.assertEqual(result.exit_code, 1)
-		result = self.app.test_cli_runner().invoke(args=['user', 'update', 'testuser', '--remove-role', 'doesnotexist'])
-		self.assertEqual(result.exit_code, 1)
-		result = self.app.test_cli_runner().invoke(args=['user', 'update', 'testuser', '--mail', 'newmail@example.com',
-		                                                 '--displayname', 'New Display Name', '--password', 'newpassword'])
-		self.assertEqual(result.exit_code, 0)
-		with self.app.test_request_context():
-			user = User.query.filter_by(loginname='testuser').first()
-			self.assertIsNotNone(user)
-			self.assertEqual(user.primary_email.address, 'newmail@example.com')
-			self.assertEqual(user.displayname, 'New Display Name')
-			self.assertTrue(user.password.verify('newpassword'))
-		result = self.app.test_cli_runner().invoke(args=['user', 'update', 'testuser', '--add-role', 'admin', '--add-role', 'test'])
-		self.assertEqual(result.exit_code, 0)
-		with self.app.test_request_context():
-			user = User.query.filter_by(loginname='testuser').first()
-			self.assertEqual(set(user.roles), {Role.query.filter_by(name='admin').one(), Role.query.filter_by(name='test').one()})
-			self.assertIn(self.get_admin_group(), user.groups)
-		result = self.app.test_cli_runner().invoke(args=['user', 'update', 'testuser', '--remove-role', 'admin'])
-		self.assertEqual(result.exit_code, 0)
-		with self.app.test_request_context():
-			user = User.query.filter_by(loginname='testuser').first()
-			self.assertEqual(user.roles, Role.query.filter_by(name='test').all())
-			self.assertNotIn(self.get_admin_group(), user.groups)
-		result = self.app.test_cli_runner().invoke(args=['user', 'update', 'testuser', '--clear-roles', '--add-role', 'admin'])
-		self.assertEqual(result.exit_code, 0)
-		with self.app.test_request_context():
-			user = User.query.filter_by(loginname='testuser').first()
-			self.assertEqual(user.roles, Role.query.filter_by(name='admin').all())
-			self.assertIn(self.get_admin_group(), user.groups)
-
-	def test_delete(self):
-		with self.app.test_request_context():
-			self.assertIsNotNone(User.query.filter_by(loginname='testuser').first())
-		result = self.app.test_cli_runner().invoke(args=['user', 'delete', 'testuser'])
-		self.assertEqual(result.exit_code, 0)
-		with self.app.test_request_context():
-			self.assertIsNone(User.query.filter_by(loginname='testuser').first())
-		result = self.app.test_cli_runner().invoke(args=['user', 'delete', 'doesnotexist'])
-		self.assertEqual(result.exit_code, 1)
-
-class TestGroupModel(UffdTestCase):
-	def test_unix_gid_generation(self):
-		self.app.config['GROUP_MIN_GID'] = 20000
-		self.app.config['GROUP_MAX_GID'] = 49999
-		Group.query.delete()
-		db.session.commit()
-		group0 = Group(name='group0', description='group0')
-		group1 = Group(name='group1', description='group1')
-		group2 = Group(name='group2', description='group2')
-		db.session.add_all([group0, group1, group2])
-		db.session.commit()
-		self.assertEqual(group0.unix_gid, 20000)
-		self.assertEqual(group1.unix_gid, 20001)
-		self.assertEqual(group2.unix_gid, 20002)
-		db.session.delete(group1)
-		db.session.commit()
-		group3 = Group(name='group3', description='group3')
-		db.session.add(group3)
-		db.session.commit()
-		self.assertEqual(group3.unix_gid, 20003)
-
-	def test_unix_gid_generation(self):
-		self.app.config['GROUP_MIN_GID'] = 20000
-		self.app.config['GROUP_MAX_GID'] = 20001
-		Group.query.delete()
-		db.session.commit()
-		group0 = Group(name='group0', description='group0')
-		group1 = Group(name='group1', description='group1')
-		db.session.add_all([group0, group1])
-		db.session.commit()
-		self.assertEqual(group0.unix_gid, 20000)
-		self.assertEqual(group1.unix_gid, 20001)
-		db.session.commit()
-		with self.assertRaises(sqlalchemy.exc.IntegrityError):
-			group2 = Group(name='group2', description='group2')
-			db.session.add(group2)
-			db.session.commit()
-
 class TestGroupViews(UffdTestCase):
 	def setUp(self):
 		super().setUp()
@@ -772,56 +492,3 @@ class TestGroupViews(UffdTestCase):
 		self.assertEqual(r.status_code, 200)
 		self.assertIsNone(Group.query.get(group1_id))
 		self.assertIsNotNone(Group.query.get(group2_id))
-
-class TestGroupCLI(UffdTestCase):
-	def setUp(self):
-		super().setUp()
-		self.client.__exit__(None, None, None)
-
-	def test_list(self):
-		result = self.app.test_cli_runner().invoke(args=['group', 'list'])
-		self.assertEqual(result.exit_code, 0)
-
-	def test_show(self):
-		result = self.app.test_cli_runner().invoke(args=['group', 'show', 'users'])
-		self.assertEqual(result.exit_code, 0)
-		result = self.app.test_cli_runner().invoke(args=['group', 'show', 'doesnotexist'])
-		self.assertEqual(result.exit_code, 1)
-
-	def test_create(self):
-		result = self.app.test_cli_runner().invoke(args=['group', 'create', 'users']) # Duplicate name
-		self.assertEqual(result.exit_code, 1)
-		result = self.app.test_cli_runner().invoke(args=['group', 'create', 'new group'])
-		self.assertEqual(result.exit_code, 1)
-		result = self.app.test_cli_runner().invoke(args=['group', 'create', 'newgroup', '--description', 'A new group'])
-		self.assertEqual(result.exit_code, 0)
-		with self.app.test_request_context():
-			group = Group.query.filter_by(name='newgroup').first()
-			self.assertIsNotNone(group)
-			self.assertEqual(group.description, 'A new group')
-
-	def test_update(self):
-		result = self.app.test_cli_runner().invoke(args=['group', 'update', 'doesnotexist', '--description', 'foo'])
-		self.assertEqual(result.exit_code, 1)
-		result = self.app.test_cli_runner().invoke(args=['group', 'update', 'users', '--description', 'New description'])
-		self.assertEqual(result.exit_code, 0)
-		with self.app.test_request_context():
-			group = Group.query.filter_by(name='users').first()
-			self.assertEqual(group.description, 'New description')
-
-	def test_update_without_description(self):
-		result = self.app.test_cli_runner().invoke(args=['group', 'update', 'users']) # Should not change anything
-		self.assertEqual(result.exit_code, 0)
-		with self.app.test_request_context():
-			group = Group.query.filter_by(name='users').first()
-			self.assertEqual(group.description, 'Base group for all users')
-
-	def test_delete(self):
-		with self.app.test_request_context():
-			self.assertIsNotNone(Group.query.filter_by(name='users').first())
-		result = self.app.test_cli_runner().invoke(args=['group', 'delete', 'users'])
-		self.assertEqual(result.exit_code, 0)
-		with self.app.test_request_context():
-			self.assertIsNone(Group.query.filter_by(name='users').first())
-		result = self.app.test_cli_runner().invoke(args=['group', 'delete', 'doesnotexist'])
-		self.assertEqual(result.exit_code, 1)