From c00544d224be8136f74efb5ca988a5f4ee40ac39 Mon Sep 17 00:00:00 2001
From: Maximilian Moser <maximilian.moser@tuwien.ac.at>
Date: Tue, 31 Dec 2024 00:41:11 +0100
Subject: [PATCH 01/11] Rename custom error log formatter

---
 invenio_config_tuw/{formatters.py => logs.py} | 4 ++--
 invenio_config_tuw/startup.py                 | 4 ++--
 2 files changed, 4 insertions(+), 4 deletions(-)
 rename invenio_config_tuw/{formatters.py => logs.py} (93%)

diff --git a/invenio_config_tuw/formatters.py b/invenio_config_tuw/logs.py
similarity index 93%
rename from invenio_config_tuw/formatters.py
rename to invenio_config_tuw/logs.py
index c61d97f..b0b1340 100644
--- a/invenio_config_tuw/formatters.py
+++ b/invenio_config_tuw/logs.py
@@ -28,7 +28,7 @@ Message:
 """
 
 
-class CustomFormatter(Formatter):
+class DetailedFormatter(Formatter):
     """Custom logging formatter that provides more details."""
 
     def __init__(self, fmt=custom_format, **kwargs):
@@ -50,4 +50,4 @@ class CustomFormatter(Formatter):
             record.user_id = None
             record.request_url = None
 
-        return super(CustomFormatter, self).format(record)
+        return super().format(record)
diff --git a/invenio_config_tuw/startup.py b/invenio_config_tuw/startup.py
index d8ed7b3..0ff1773 100644
--- a/invenio_config_tuw/startup.py
+++ b/invenio_config_tuw/startup.py
@@ -18,7 +18,7 @@ from logging.handlers import SMTPHandler
 
 from invenio_rdm_records.services.search_params import MyDraftsParam
 
-from .formatters import CustomFormatter
+from .logs import DetailedFormatter
 
 
 def register_smtp_error_handler(app):
@@ -68,7 +68,7 @@ def register_smtp_error_handler(app):
         )
         mail_handler.name = handler_name
         mail_handler.setLevel(ERROR)
-        mail_handler.setFormatter(CustomFormatter())
+        mail_handler.setFormatter(DetailedFormatter())
 
         # attach to the application
         app.logger.addHandler(mail_handler)
-- 
GitLab


From d60e8b06892d76386aed9da678f3ea5efabcf11a Mon Sep 17 00:00:00 2001
From: Maximilian Moser <maximilian.moser@tuwien.ac.at>
Date: Tue, 31 Dec 2024 17:07:06 +0100
Subject: [PATCH 02/11] Fix incorrect HTTP code check in ROR API call

---
 invenio_config_tuw/tasks.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/invenio_config_tuw/tasks.py b/invenio_config_tuw/tasks.py
index b520b9c..7136236 100644
--- a/invenio_config_tuw/tasks.py
+++ b/invenio_config_tuw/tasks.py
@@ -30,7 +30,7 @@ def get_tuw_ror_aliases() -> List[str]:
     """Fetch the aliases of TU Wien known to ROR."""
     try:
         response = requests.get("https://api.ror.org/organizations/04d836q62")
-        if response == 200:
+        if response.ok:
             tuw_ror = response.json()
             tuw_ror_names = [tuw_ror["name"], *tuw_ror["acronyms"], *tuw_ror["aliases"]]
             return tuw_ror_names
-- 
GitLab


From 3af89587fb05d62978fae0c7956f779ff74acfd9 Mon Sep 17 00:00:00 2001
From: Maximilian Moser <maximilian.moser@tuwien.ac.at>
Date: Fri, 3 Jan 2025 01:07:22 +0100
Subject: [PATCH 03/11] Add TUW ROR identifier to default affiliation

---
 invenio_config_tuw/tiss/models.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/invenio_config_tuw/tiss/models.py b/invenio_config_tuw/tiss/models.py
index bb8168f..9bc38f9 100644
--- a/invenio_config_tuw/tiss/models.py
+++ b/invenio_config_tuw/tiss/models.py
@@ -79,7 +79,7 @@ class Employee:
             "given_name": self.first_name,
             "family_name": self.last_name,
             "identifiers": ids,
-            "affiliations": [{"name": "TU Wien"}],
+            "affiliations": [{"id": "04d836q62", "name": "TU Wien"}],
         }
 
     def __hash__(self):
-- 
GitLab


From 8f5fb66971c1cb0cb435a2994da12174d542f700 Mon Sep 17 00:00:00 2001
From: Maximilian Moser <maximilian.moser@tuwien.ac.at>
Date: Fri, 3 Jan 2025 02:05:01 +0100
Subject: [PATCH 04/11] Provide fallback templates for rendering emails and
 curation settings

* they come in handy for the tests, to avoid a dependency on
  Invenio-Theme-TUW
---
 invenio_config_tuw/tasks.py                   | 10 +++-
 .../users/templates/curation_settings.html    | 50 +++++++++++++++++++
 .../templates/mails/record_published.html     | 30 +++++++++++
 .../templates/mails/record_published.txt      | 23 +++++++++
 invenio_config_tuw/views.py                   |  3 +-
 5 files changed, 113 insertions(+), 3 deletions(-)
 create mode 100644 invenio_config_tuw/users/templates/curation_settings.html
 create mode 100644 invenio_config_tuw/users/templates/mails/record_published.html
 create mode 100644 invenio_config_tuw/users/templates/mails/record_published.txt

diff --git a/invenio_config_tuw/tasks.py b/invenio_config_tuw/tasks.py
index 7136236..ff9cb47 100644
--- a/invenio_config_tuw/tasks.py
+++ b/invenio_config_tuw/tasks.py
@@ -206,13 +206,19 @@ def send_publication_notification_email(recid: str, user_id: Optional[str] = Non
             user = owner.resolve()
 
     html_message = render_template(
-        "invenio_theme_tuw/mails/record_published.html",
+        [
+            "invenio_theme_tuw/mails/record_published.html",
+            "mails/record_published.html",
+        ],
         uploader=user,
         record=record,
         app=current_app,
     )
     message = render_template(
-        "invenio_theme_tuw/mails/record_published.txt",
+        [
+            "invenio_theme_tuw/mails/record_published.txt",
+            "mails/record_published.txt",
+        ],
         uploader=user,
         record=record,
         app=current_app,
diff --git a/invenio_config_tuw/users/templates/curation_settings.html b/invenio_config_tuw/users/templates/curation_settings.html
new file mode 100644
index 0000000..da5a96b
--- /dev/null
+++ b/invenio_config_tuw/users/templates/curation_settings.html
@@ -0,0 +1,50 @@
+{#-
+  Copyright (C) 2025 TU Wien.
+
+  Invenio Config TUW is free software; you can redistribute it and/or modify
+  it under the terms of the MIT License; see LICENSE file for more details.
+#}
+{%- extends config.USERPROFILES_SETTINGS_TEMPLATE %}
+{%- from "invenio_userprofiles/settings/_macros.html" import render_field, form_errors %}
+
+{%- block settings_content scoped %}
+  <section aria-label="{{ _('Curation') }}" class="ui segments">
+    <div class="ui segment secondary">
+      <i class="file icon" aria-hidden="true"></i>
+      <strong class="header item">{{ _("Curation") }}</strong>
+    </div>
+    <div class="ui segment">
+      <form method="POST" name="preferences_curation_form" class="ui form">
+        {%- set form = preferences_curation_form %}
+        {%- for field in form %}
+          {%- if field.widget.input_type == 'hidden' %}
+            {{ field() }}
+          {%- endif %}
+        {%- endfor %}
+
+        {%- set consent_field = form.consent %}
+        {%- set consent_given = current_user.preferences.get("curation_consent", False) %}
+        <div class="field">
+          <label for="{{ consent_field.id }}">{{ consent_field.label }}</label>
+          <p><small>{{ consent_field.description }}</small></p>
+
+          <div class="ui toggle on-off checkbox">
+            <input type="checkbox" name="{{ consent_field.id }}" id="{{ consent_field.id }}" {{ "checked" if consent_given else "" }} />
+          <label for="{{ consent_field.id }}">Curate my records</label>
+          </div>
+        </div>
+
+        <div class="form-actions">
+          <a href="." class="ui labeled icon button mt-5">
+            <i class="close icon" aria-hidden="true"></i>
+            {{ _('Cancel') }}
+          </a>
+          <button type="submit" name="submit" value="{{ form._prefix }}" class="ui primary labeled icon button mt-5">
+            <i class="check icon" aria-hidden="true"></i>
+            {{ _('Update curation preferences') }}
+          </button>
+        </div>
+      </form>
+    </div>
+  </section>
+{% endblock %}
diff --git a/invenio_config_tuw/users/templates/mails/record_published.html b/invenio_config_tuw/users/templates/mails/record_published.html
new file mode 100644
index 0000000..f2914dc
--- /dev/null
+++ b/invenio_config_tuw/users/templates/mails/record_published.html
@@ -0,0 +1,30 @@
+{#-
+  Copyright (C) 2025 TU Wien.
+
+  Invenio Config TUW is free software; you can redistribute it and/or modify
+  it under the terms of the MIT License; see LICENSE file for more details.
+-#}
+{%- if record.pids and record.pids.doi %}
+    {%- set doi = record.pids.doi.identifier %}
+    {%- set doi_base_url = "https://doi.org/" if not app.config["DATACITE_TEST_MODE"] else "https://handle.test.datacite.org/"  %}
+    {%- set doi_ref = "DOI" if not app.config["DATACITE_TEST_MODE"] else "DOI-like handle"  %}
+    {%- set avail_message = 'It is now available under the following ' + doi_ref + ': <a href="' + doi_base_url + doi + '">' + doi_base_url + doi + '</a>' %}
+{%- else %}
+    {%- set avail_message = 'It is now available under the following URL: <a href="' + record.links.self_html + '">' + record.links.self_html + '</a>' %}
+{%- endif %}
+<p>
+  Dear {{ uploader.user_profile.full_name }},
+</p>
+<p>
+  Your record "{{ record.metadata.title }}" just got published!
+  <br />
+  {{ avail_message|safe }}
+</p>
+<p>
+  Metadata edits for this record will <em>not</em> require another review.
+</p>
+<p>
+  Cheers,
+  <br />
+  The team at the Center for Research Data Management
+</p>
diff --git a/invenio_config_tuw/users/templates/mails/record_published.txt b/invenio_config_tuw/users/templates/mails/record_published.txt
new file mode 100644
index 0000000..211355a
--- /dev/null
+++ b/invenio_config_tuw/users/templates/mails/record_published.txt
@@ -0,0 +1,23 @@
+{#-
+  Copyright (C) 2025 TU Wien.
+
+  Invenio Config TUW is free software; you can redistribute it and/or modify
+  it under the terms of the MIT License; see LICENSE file for more details.
+-#}
+{%- if record.pids and record.pids.doi %}
+    {%- set doi = record.pids.doi.identifier %}
+    {%- set doi_base_url = "https://doi.org/" if not app.config["DATACITE_TEST_MODE"] else "https://handle.test.datacite.org/"  %}
+    {%- set doi_ref = "DOI" if not app.config["DATACITE_TEST_MODE"] else "DOI-like handle"  %}
+    {%- set avail_message = "It is now available under the following " + doi_ref + ": " + doi_base_url + doi %}
+{%- else %}
+    {%- set avail_message = "It is now available under the following URL: " + record.links.self_html %}
+{%- endif %}
+Dear {{ uploader.user_profile.full_name }},
+
+Your record "{{ record.metadata.title }}" just got published!
+{{ avail_message|safe }}
+
+Metadata edits for this record will *not* require another review.
+
+Cheers,
+The team at the Center for Research Data Management
diff --git a/invenio_config_tuw/views.py b/invenio_config_tuw/views.py
index b294d22..e17a6da 100644
--- a/invenio_config_tuw/views.py
+++ b/invenio_config_tuw/views.py
@@ -17,6 +17,7 @@ blueprint = Blueprint(
     "invenio_config_tuw_settings",
     __name__,
     url_prefix="/account/settings/curation",
+    template_folder="templates",
 )
 
 
@@ -40,6 +41,6 @@ def curation_settings_view():
             flash(("Curation settings were updated."), category="success")
 
     return render_template(
-        "invenio_theme_tuw/settings/curation.html",
+        ["invenio_theme_tuw/settings/curation.html", "curation_settings.html"],
         preferences_curation_form=preferences_curation_form,
     )
-- 
GitLab


From e9e377c2a726e6de8ded7e8d172c936a2f56385b Mon Sep 17 00:00:00 2001
From: Maximilian Moser <maximilian.moser@tuwien.ac.at>
Date: Thu, 2 Jan 2025 12:10:44 +0100
Subject: [PATCH 05/11] Log warning and abort if no publication email recipient
 can be determined

---
 invenio_config_tuw/tasks.py | 5 +++++
 1 file changed, 5 insertions(+)

diff --git a/invenio_config_tuw/tasks.py b/invenio_config_tuw/tasks.py
index ff9cb47..a9dad2d 100644
--- a/invenio_config_tuw/tasks.py
+++ b/invenio_config_tuw/tasks.py
@@ -204,6 +204,11 @@ def send_publication_notification_email(recid: str, user_id: Optional[str] = Non
         owner = record._obj.parent.access.owner
         if owner is not None and owner.owner_type == "user":
             user = owner.resolve()
+        else:
+            current_app.logger.warn(
+                f"Couldn't find owner of record '{recid}' for sending email!"
+            )
+            return
 
     html_message = render_template(
         [
-- 
GitLab


From d008de46ecac29dac4d2eeb2b03bfd0521d73949 Mon Sep 17 00:00:00 2001
From: Maximilian Moser <maximilian.moser@tuwien.ac.at>
Date: Tue, 31 Dec 2024 00:25:17 +0100
Subject: [PATCH 06/11] Refactor user-related modules into their own namespace

* to make it more obvious that they are all related to user models
* also, rename the modules to better reflect their content
* also, rename some content (e.g. `CurationForm` to `CurationPreferencesForm`)
---
 invenio_config_tuw/auth/utils.py              |  2 +-
 invenio_config_tuw/config.py                  | 10 +++--
 invenio_config_tuw/users/__init__.py          | 36 ++++++++++++++++
 .../{curation.py => users/preferences.py}     |  4 +-
 .../{forms.py => users/registration.py}       |  2 +-
 invenio_config_tuw/{ => users}/schemas.py     |  2 +-
 invenio_config_tuw/{ => users}/utils.py       | 43 +------------------
 invenio_config_tuw/{ => users}/views.py       | 10 ++---
 pyproject.toml                                |  4 +-
 9 files changed, 57 insertions(+), 56 deletions(-)
 create mode 100644 invenio_config_tuw/users/__init__.py
 rename invenio_config_tuw/{curation.py => users/preferences.py} (96%)
 rename invenio_config_tuw/{forms.py => users/registration.py} (99%)
 rename invenio_config_tuw/{ => users}/schemas.py (96%)
 rename invenio_config_tuw/{ => users}/utils.py (67%)
 rename invenio_config_tuw/{ => users}/views.py (84%)

diff --git a/invenio_config_tuw/auth/utils.py b/invenio_config_tuw/auth/utils.py
index 6373150..d314ac3 100644
--- a/invenio_config_tuw/auth/utils.py
+++ b/invenio_config_tuw/auth/utils.py
@@ -12,7 +12,7 @@ from flask import current_app
 from invenio_accounts.proxies import current_datastore
 from invenio_db import db
 
-from ..utils import get_user_by_username
+from ..users.utils import get_user_by_username
 
 
 def create_username_from_info(user_info):
diff --git a/invenio_config_tuw/config.py b/invenio_config_tuw/config.py
index 3f09b57..794e00e 100644
--- a/invenio_config_tuw/config.py
+++ b/invenio_config_tuw/config.py
@@ -14,15 +14,19 @@ from invenio_oauthclient.views.client import auto_redirect_login
 from invenio_rdm_records.config import RDM_RECORDS_REVIEWS
 
 from .auth import TUWSSOSettingsHelper
-from .forms import tuw_registration_form
 from .permissions import (
     TUWCommunityPermissionPolicy,
     TUWRecordPermissionPolicy,
     TUWRequestsPermissionPolicy,
 )
-from .schemas import TUWUserPreferencesSchema, TUWUserProfileSchema, TUWUserSchema
 from .services import TUWRecordsComponents
-from .utils import check_user_email_for_tuwien, current_user_as_creator
+from .users import (
+    TUWUserPreferencesSchema,
+    TUWUserProfileSchema,
+    TUWUserSchema,
+    tuw_registration_form,
+)
+from .users.utils import check_user_email_for_tuwien, current_user_as_creator
 
 # Invenio-Config-TUW
 # ==================
diff --git a/invenio_config_tuw/users/__init__.py b/invenio_config_tuw/users/__init__.py
new file mode 100644
index 0000000..f61dee6
--- /dev/null
+++ b/invenio_config_tuw/users/__init__.py
@@ -0,0 +1,36 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2024-2025 TU Wien.
+#
+# Invenio-Config-TUW is free software; you can redistribute it and/or modify
+# it under the terms of the MIT License; see LICENSE file for more details.
+
+"""Customizations for the user model at TU Wien.
+
+This includes the curation consent for record metadata as extension for the user's
+preferences, and a custom registration form which requires the terms of use to be
+accepted.
+"""
+
+from .preferences import CurationPreferencesForm, CurationPreferencesProxy
+from .registration import tuw_registration_form
+from .schemas import TUWUserPreferencesSchema, TUWUserProfileSchema, TUWUserSchema
+from .utils import (
+    check_user_email_for_tuwien,
+    current_user_as_creator,
+    get_user_by_username,
+)
+from .views import user_settings_blueprint
+
+__all__ = (
+    "CurationPreferencesForm",
+    "CurationPreferencesProxy",
+    "TUWUserPreferencesSchema",
+    "TUWUserProfileSchema",
+    "TUWUserSchema",
+    "check_user_email_for_tuwien",
+    "current_user_as_creator",
+    "get_user_by_username",
+    "tuw_registration_form",
+    "user_settings_blueprint",
+)
diff --git a/invenio_config_tuw/curation.py b/invenio_config_tuw/users/preferences.py
similarity index 96%
rename from invenio_config_tuw/curation.py
rename to invenio_config_tuw/users/preferences.py
index b544b78..14ac06c 100644
--- a/invenio_config_tuw/curation.py
+++ b/invenio_config_tuw/users/preferences.py
@@ -1,6 +1,6 @@
 # -*- coding: utf-8 -*-
 #
-# Copyright (C) 2023 TU Wien.
+# Copyright (C) 2023-2025 TU Wien.
 #
 # Invenio-Config-TUW is free software; you can redistribute it and/or modify
 # it under the terms of the MIT License; see LICENSE file for more details.
@@ -37,7 +37,7 @@ class CurationPreferencesProxy:
         return f"curation_{attr}" in self._user.preferences
 
 
-class CurationForm(FlaskForm):
+class CurationPreferencesForm(FlaskForm):
     """Form for editing a user's curation preferences."""
 
     proxy_cls = CurationPreferencesProxy
diff --git a/invenio_config_tuw/forms.py b/invenio_config_tuw/users/registration.py
similarity index 99%
rename from invenio_config_tuw/forms.py
rename to invenio_config_tuw/users/registration.py
index 5e62a5f..ac00174 100644
--- a/invenio_config_tuw/forms.py
+++ b/invenio_config_tuw/users/registration.py
@@ -1,6 +1,6 @@
 # -*- coding: utf-8 -*-
 #
-# Copyright (C) 2020-2024 TU Wien.
+# Copyright (C) 2020-2025 TU Wien.
 #
 # Invenio-Config-TUW is free software; you can redistribute it and/or modify
 # it under the terms of the MIT License; see LICENSE file for more details.
diff --git a/invenio_config_tuw/schemas.py b/invenio_config_tuw/users/schemas.py
similarity index 96%
rename from invenio_config_tuw/schemas.py
rename to invenio_config_tuw/users/schemas.py
index 60b4b64..1085798 100644
--- a/invenio_config_tuw/schemas.py
+++ b/invenio_config_tuw/users/schemas.py
@@ -1,6 +1,6 @@
 # -*- coding: utf-8 -*-
 #
-# Copyright (C) 2023 TU Wien.
+# Copyright (C) 2023-2025 TU Wien.
 #
 # Invenio-Config-TUW is free software; you can redistribute it and/or modify
 # it under the terms of the MIT License; see LICENSE file for more details.
diff --git a/invenio_config_tuw/utils.py b/invenio_config_tuw/users/utils.py
similarity index 67%
rename from invenio_config_tuw/utils.py
rename to invenio_config_tuw/users/utils.py
index e7a11bf..823aae3 100644
--- a/invenio_config_tuw/utils.py
+++ b/invenio_config_tuw/users/utils.py
@@ -1,62 +1,23 @@
 # -*- coding: utf-8 -*-
 #
-# Copyright (C) 2020-2022 TU Wien.
+# Copyright (C) 2020-2025 TU Wien.
 #
 # Invenio-Config-TUW is free software; you can redistribute it and/or modify
 # it under the terms of the MIT License; see LICENSE file for more details.
 
-"""Utility functions."""
+"""User-related utility functions."""
 
 from typing import Dict, Tuple
 
-from flask_principal import Identity
 from flask_security import current_user
-from invenio_access import any_user
-from invenio_access.utils import get_identity
-from invenio_accounts import current_accounts
 from invenio_accounts.models import User
 
-# Utilities for internal use
-# --------------------------
-
 
 def get_user_by_username(username):
     """Get the user identified by the username."""
     return User.query.filter(User.username == username).one_or_none()
 
 
-def get_user(identifier):
-    """Get the user identified by the given ID, email or username."""
-    user = current_accounts.datastore.get_user(identifier)
-    if user is None:
-        get_user_by_username(identifier)
-
-    return user
-
-
-def get_identity_for_user(user):
-    """Get the Identity for the user specified via email, ID or username."""
-    identity = None
-    if user is not None:
-        # note: this seems like the canonical way to go
-        #       'as_user' can be either an integer (id) or email address
-        u = get_user(user)
-        if u is not None:
-            identity = get_identity(u)
-        else:
-            raise LookupError("user not found: %s" % user)
-
-    if identity is None:
-        identity = Identity(1)
-
-    identity.provides.add(any_user)
-    return identity
-
-
-# Utilities for invenio configuration
-# -----------------------------------
-
-
 def check_user_email_for_tuwien(user):
     """Check if the user's email belongs to TU Wien (but not as a student)."""
     domain = user.email.split("@")[-1]
diff --git a/invenio_config_tuw/views.py b/invenio_config_tuw/users/views.py
similarity index 84%
rename from invenio_config_tuw/views.py
rename to invenio_config_tuw/users/views.py
index e17a6da..5b0c350 100644
--- a/invenio_config_tuw/views.py
+++ b/invenio_config_tuw/users/views.py
@@ -1,6 +1,6 @@
 # -*- coding: utf-8 -*-
 #
-# Copyright (C) 2024 TU Wien.
+# Copyright (C) 2024-2025 TU Wien.
 #
 # Invenio-Config-TUW is free software; you can redistribute it and/or modify
 # it under the terms of the MIT License; see LICENSE file for more details.
@@ -11,9 +11,9 @@ from flask import Blueprint, current_app, flash, render_template, request
 from flask_login import current_user, login_required
 from invenio_db import db
 
-from .curation import CurationForm
+from .preferences import CurationPreferencesForm
 
-blueprint = Blueprint(
+user_settings_blueprint = Blueprint(
     "invenio_config_tuw_settings",
     __name__,
     url_prefix="/account/settings/curation",
@@ -21,11 +21,11 @@ blueprint = Blueprint(
 )
 
 
-@blueprint.route("/", methods=["GET", "POST"])
+@user_settings_blueprint.route("/", methods=["GET", "POST"])
 @login_required
 def curation_settings_view():
     """Page for the curation consent setting in user profiles."""
-    preferences_curation_form = CurationForm(
+    preferences_curation_form = CurationPreferencesForm(
         formdata=None, obj=current_user, prefix="preferences-curation"
     )
 
diff --git a/pyproject.toml b/pyproject.toml
index 5ba6349..ec008a7 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
 # -*- coding: utf-8 -*-
 #
-# Copyright (C) 2024 TU Wien.
+# Copyright (C) 2024-2025 TU Wien.
 #
 # Invenio-Config-TUW is free software; you can redistribute it and/or modify
 # it under the terms of the MIT License; see LICENSE file for more details.
@@ -69,7 +69,7 @@ invenio_config_tuw = "invenio_config_tuw:InvenioConfigTUW"
 invenio_config_tuw = "invenio_config_tuw:InvenioConfigTUW"
 
 [project.entry-points."invenio_base.blueprints"]
-invenio_config_tuw_settings = "invenio_config_tuw.views:blueprint"
+invenio_config_tuw_settings = "invenio_config_tuw.users.views:user_settings_blueprint"
 
 [project.entry-points."invenio_base.finalize_app"]
 invenio_config_tuw_mail_handler = "invenio_config_tuw.startup:register_smtp_error_handler"
-- 
GitLab


From 9f08910906f42d210968f0b1b10d83c9e1929293 Mon Sep 17 00:00:00 2001
From: Maximilian Moser <maximilian.moser@tuwien.ac.at>
Date: Fri, 3 Jan 2025 14:39:53 +0100
Subject: [PATCH 07/11] Update sonar-project.properties

* tell it where to find the coverage report
---
 sonar-project.properties | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/sonar-project.properties b/sonar-project.properties
index 906f20f..3e8029f 100644
--- a/sonar-project.properties
+++ b/sonar-project.properties
@@ -1,2 +1,5 @@
 sonar.projectKey=fairdata_invenio-config-tuw_AXp2vLO0Qz8akf6NDIUA
 sonar.qualitygate.wait=true
+sonar.python.version=3
+sonar.python.coverage.reportPaths=coverage.xml
+sonar.coverage.exclusions=tests/*,**/*.js
-- 
GitLab


From cd49044b0e80e60cf201bf8db3fa309d2d5c559e Mon Sep 17 00:00:00 2001
From: Maximilian Moser <maximilian.moser@tuwien.ac.at>
Date: Tue, 31 Dec 2024 17:20:55 +0100
Subject: [PATCH 08/11] Add tests for the TISS integration

---
 pyproject.toml                 |   5 +-
 tests/conftest.py              |  49 +++++-----
 tests/data/ror-tuw.json        | 117 +++++++++++++++++++++++
 tests/data/tiss/e000.json      |  51 ++++++++++
 tests/data/tiss/e123.json      |  63 +++++++++++++
 tests/data/tiss/e420.json      | 127 +++++++++++++++++++++++++
 tests/test_tiss_integration.py | 164 +++++++++++++++++++++++++++++++++
 7 files changed, 554 insertions(+), 22 deletions(-)
 create mode 100644 tests/data/ror-tuw.json
 create mode 100644 tests/data/tiss/e000.json
 create mode 100644 tests/data/tiss/e123.json
 create mode 100644 tests/data/tiss/e420.json
 create mode 100644 tests/test_tiss_integration.py

diff --git a/pyproject.toml b/pyproject.toml
index ec008a7..5e87b3c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -54,8 +54,11 @@ dependencies = [
 tests = [
     "pytest-ruff>=0.4.1",
     "pytest-black>=0.3.0",
-    "pytest-invenio>=1.4.0",
+    "pytest-invenio>=1.4.0,<3.0.0",
     "invenio-search[opensearch2]>=2.1.0,<3.0.0",
+    "httpretty>=1.1.4",
+    "ipdb>=0.13.13",
+    "ipython>=8.18.1",
 ]
 
 [project.urls]
diff --git a/tests/conftest.py b/tests/conftest.py
index bd9c6d0..58a9c75 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -13,30 +13,37 @@ fixtures are available.
 
 
 import pytest
-from flask import Flask
-from invenio_i18n import Babel
-
-from invenio_config_tuw import InvenioConfigTUW
-
-
-@pytest.fixture(scope="module")
-def celery_config():
-    """Override pytest-invenio fixture.
-
-    TODO: Remove this fixture if you add Celery support.
-    """
-    return {}
+from invenio_access.permissions import system_identity
+from invenio_app.factory import create_api
+from invenio_records_resources.proxies import current_service_registry
+from invenio_vocabularies.proxies import current_service as vocab_svc
 
 
 @pytest.fixture(scope="module")
 def create_app(instance_path):
-    """Application factory fixture."""
+    """Create test app."""
+    return create_api
 
-    def factory(**config):
-        app = Flask("testapp", instance_path=instance_path)
-        app.config.update(**config)
-        Babel(app)
-        InvenioConfigTUW(app)
-        return app
 
-    return factory
+@pytest.fixture(scope="module")
+def app_config(app_config):
+    """Testing configuration."""
+    return app_config
+
+
+@pytest.fixture()
+def affiliations(db):
+    """Creates the required affiliations vocabulary for the tests."""
+    vocab_svc.create_type(system_identity, "affiliations", "aff")
+
+    service = current_service_registry.get("affiliations")
+    service.create(
+        system_identity,
+        {
+            "acronym": "TUW",
+            "id": "04d836q62",
+            "identifiers": [{"identifier": "04d836q62", "scheme": "ror"}],
+            "name": "TU Wien",
+            "title": {"de": "Technische Universit\xE4t Wien", "en": "TU Wien"},
+        },
+    )
diff --git a/tests/data/ror-tuw.json b/tests/data/ror-tuw.json
new file mode 100644
index 0000000..870dbb4
--- /dev/null
+++ b/tests/data/ror-tuw.json
@@ -0,0 +1,117 @@
+{
+  "id": "https://ror.org/04d836q62",
+  "name": "TU Wien",
+  "email_address": null,
+  "ip_addresses": [],
+  "established": 1815,
+  "types": [
+    "Education",
+    "Funder"
+  ],
+  "relationships": [
+    {
+      "label": "Christian Doppler Laboratory for Thermoelectricity",
+      "type": "Child",
+      "id": "https://ror.org/01cbw5x35"
+    },
+    {
+      "label": "Vienna Center for Quantum Science and Technology",
+      "type": "Child",
+      "id": "https://ror.org/014cpn338"
+    },
+    {
+      "label": "Complexity Science Hub Vienna",
+      "type": "Related",
+      "id": "https://ror.org/023dz9m50"
+    }
+  ],
+  "addresses": [
+    {
+      "lat": 48.20849,
+      "lng": 16.37208,
+      "state": null,
+      "state_code": null,
+      "city": "Vienna",
+      "geonames_city": {
+        "id": 2761369,
+        "city": "Vienna",
+        "geonames_admin1": {
+          "name": "Vienna",
+          "id": null,
+          "ascii_name": null,
+          "code": "AT.9"
+        },
+        "geonames_admin2": {
+          "name": null,
+          "id": null,
+          "ascii_name": null,
+          "code": null
+        },
+        "license": {
+          "attribution": "Data from geonames.org under a CC-BY 3.0 license",
+          "license": "http://creativecommons.org/licenses/by/3.0/"
+        },
+        "nuts_level1": {
+          "name": null,
+          "code": null
+        },
+        "nuts_level2": {
+          "name": null,
+          "code": null
+        },
+        "nuts_level3": {
+          "name": null,
+          "code": null
+        }
+      },
+      "postcode": null,
+      "primary": false,
+      "line": null,
+      "country_geonames_id": null
+    }
+  ],
+  "links": [
+    "https://www.tuwien.at"
+  ],
+  "aliases": [
+    "Technische Universität Wien",
+    "Vienna University of Technology"
+  ],
+  "acronyms": [
+    "TUW"
+  ],
+  "status": "active",
+  "wikipedia_url": "https://en.wikipedia.org/wiki/TU_Wien",
+  "labels": [],
+  "country": {
+    "country_name": "Austria",
+    "country_code": "AT"
+  },
+  "external_ids": {
+    "ISNI": {
+      "preferred": "0000 0004 1937 0669",
+      "all": [
+        "0000 0004 1937 0669"
+      ]
+    },
+    "FundRef": {
+      "preferred": "501100004729",
+      "all": [
+        "501100004729",
+        "100007223",
+        "501100012650"
+      ]
+    },
+    "Wikidata": {
+      "preferred": "Q689400",
+      "all": [
+        "Q689400",
+        "Q757615"
+      ]
+    },
+    "GRID": {
+      "preferred": "grid.5329.d",
+      "all": "grid.5329.d"
+    }
+  }
+}
diff --git a/tests/data/tiss/e000.json b/tests/data/tiss/e000.json
new file mode 100644
index 0000000..9e388e9
--- /dev/null
+++ b/tests/data/tiss/e000.json
@@ -0,0 +1,51 @@
+{
+  "tiss_id": 3000,
+  "oid": 1094501,
+  "code": "E000",
+  "number": "E000",
+  "name_de": "Technische Universität Wien",
+  "name_en": "TU Wien",
+  "type": "GRU",
+  "card_uri": "/adressbuch/adressbuch/orgeinheit/3000",
+  "manager": null,
+  "employees": [],
+  "child_orgs_refs": [
+    {
+      "tiss_id": 562,
+      "oid": 666546,
+      "code": "E420",
+      "number": "E420",
+      "name_de": "Fakultät für Wissenschaft",
+      "name_en": "Faculty of Science",
+      "type": "FAK",
+      "manager": {
+        "tiss_id": 36532,
+        "oid": 293705,
+        "old_tiss_ids": [],
+        "first_name": "Boss",
+        "last_name": "Research",
+        "pseudoperson": false,
+        "preceding_titles": "Univ.Prof. Dr.",
+        "postpositioned_titles": null,
+        "orcid": "0000-0003-1337-0425",
+        "card_uri": "/person/1337",
+        "picture_uri": null,
+        "main_phone_number": null,
+        "main_email": "boss.research@tuwien.ac.at",
+        "other_emails": [
+          "dean-sci@tuwien.ac.at"
+        ]
+      }
+    },
+    {
+      "tiss_id": 123,
+      "oid": 20685,
+      "code": "E123",
+      "number": "E123",
+      "name_de": "Sonstige Einrichtungen",
+      "name_en": "Other Institutions",
+      "type": "GRU",
+      "manager": null
+    }
+  ]
+}
diff --git a/tests/data/tiss/e123.json b/tests/data/tiss/e123.json
new file mode 100644
index 0000000..216b9e9
--- /dev/null
+++ b/tests/data/tiss/e123.json
@@ -0,0 +1,63 @@
+{
+  "tiss_id": 123,
+  "oid": 20685,
+  "code": "E123",
+  "number": "E123",
+  "name_de": "Sonstige Einrichtungen",
+  "name_en": "Other Institutions",
+  "type": "GRU",
+  "card_uri": "/adressbuch/adressbuch/orgeinheit/123",
+  "manager": null,
+  "websites": [],
+  "addresses": [
+    {
+      "street": "Karlsplatz 13",
+      "zip_code": "1040",
+      "city": "Wien",
+      "country": "AT",
+      "co": "403"
+    }
+  ],
+  "emails": [
+    "other@tuwien.ac.at"
+  ],
+  "phone_numbers": [
+    "+43 1 58801 - 013 37"
+  ],
+  "parent_org_ref": {
+    "tiss_id": 3000,
+    "oid": 1094501,
+    "code": "E000",
+    "number": "E000",
+    "name_de": "Technische Universität Wien",
+    "name_en": "TU Wien",
+    "type": "GRU",
+    "card_uri": "/adressbuch/adressbuch/orgeinheit/3000",
+    "manager": null
+  },
+  "employees": [
+    {
+      "tiss_id": 719,
+      "oid": 3306185,
+      "old_tiss_ids": [],
+      "first_name": "IO",
+      "last_name": "Wisp",
+      "pseudoperson": false,
+      "preceding_titles": "Dipl.-Ing.",
+      "postpositioned_titles": null,
+      "orcid": null,
+      "card_uri": "/person/719",
+      "picture_uri": null,
+      "main_phone_number": null,
+      "main_email": "io@tuwien.ac.at",
+      "other_emails": [
+        "support@tuwien.ac.at"
+      ],
+      "function_tiss_id": 1326,
+      "display_function": "General University Employee",
+      "function_group_tiss_id": 1005,
+      "display_function_group": "Non-scientific Staff"
+    }
+  ],
+  "child_orgs_refs": []
+}
diff --git a/tests/data/tiss/e420.json b/tests/data/tiss/e420.json
new file mode 100644
index 0000000..ff3ac4f
--- /dev/null
+++ b/tests/data/tiss/e420.json
@@ -0,0 +1,127 @@
+{
+  "tiss_id": 562,
+  "oid": 666546,
+  "code": "E420",
+  "number": "E420",
+  "name_de": "Fakultät für Wissenschaft",
+  "name_en": "Faculty of Science",
+  "type": "FAK",
+  "card_uri": "/adressbuch/adressbuch/orgeinheit/562",
+  "manager": {
+    "tiss_id": 36532,
+    "oid": 293705,
+    "old_tiss_ids": [],
+    "first_name": "Boss",
+    "last_name": "Research",
+    "pseudoperson": false,
+    "preceding_titles": "Univ.Prof. Dr.",
+    "postpositioned_titles": null,
+    "orcid": null,
+    "card_uri": "/person/1337",
+    "picture_uri": null,
+    "main_phone_number": null,
+    "main_email": "boss.research@tuwien.ac.at",
+    "other_emails": [
+      "dean-sci@tuwien.ac.at"
+    ]
+  },
+  "websites": [
+    {
+      "title": "Homepage",
+      "uri": "https://institute.tuwien.ac.at/science"
+    }
+  ],
+  "addresses": [
+    {
+      "street": "Karlsplatz 13",
+      "zip_code": "1040",
+      "city": "Wien",
+      "country": "AT",
+      "co": "403"
+    }
+  ],
+  "emails": [
+    "science@tuwien.ac.at"
+  ],
+  "phone_numbers": [
+    "+43 1 58801 - 420 69"
+  ],
+  "parent_org_ref": {
+    "tiss_id": 3000,
+    "oid": 1094501,
+    "code": "E000",
+    "number": "E000",
+    "name_de": "Technische Universität Wien",
+    "name_en": "TU Wien",
+    "type": "GRU",
+    "card_uri": "/adressbuch/adressbuch/orgeinheit/3000",
+    "manager": null
+  },
+  "employees": [
+    {
+      "tiss_id": 1337,
+      "oid": 293705,
+      "old_tiss_ids": [],
+      "first_name": "Boss",
+      "last_name": "Research",
+      "pseudoperson": false,
+      "preceding_titles": "Univ.Prof. Dr.",
+      "postpositioned_titles": null,
+      "orcid": "0000-0003-1337-0425",
+      "card_uri": "/person/1337",
+      "picture_uri": null,
+      "main_phone_number": null,
+      "main_email": "boss.research@tuwien.ac.at",
+      "other_emails": [
+        "dean-sci@tuwien.ac.at"
+      ],
+      "function_tiss_id": 1271,
+      "display_function": "Dekan",
+      "function_group_tiss_id": 1043,
+      "display_function_group": "Dekan_innen"
+    },
+    {
+      "tiss_id": 13371337,
+      "oid": 8134262,
+      "old_tiss_ids": [],
+      "first_name": "John",
+      "last_name": "Darksouls",
+      "pseudoperson": false,
+      "preceding_titles": "Univ.Prof. Dr.-Ing.",
+      "postpositioned_titles": null,
+      "orcid": "0000-0003-0405-6178",
+      "card_uri": "/person/13371337",
+      "picture_uri": null,
+      "main_phone_number": null,
+      "main_email": "john.darksouls@tuwien.ac.at",
+      "other_emails": [
+        "the.elden.ring@tuwien.ac.at"
+      ],
+      "function_tiss_id": 1272,
+      "display_function": "Studiendekan",
+      "function_group_tiss_id": 1042,
+      "display_function_group": "Studiendekan_innen"
+    },
+    {
+      "tiss_id": 420691337,
+      "oid": 397565,
+      "old_tiss_ids": [],
+      "first_name": "Anders",
+      "last_name": "Ericsson",
+      "pseudoperson": false,
+      "preceding_titles": "Ao.Univ.Prof. Dipl.-Ing. Dr.techn.",
+      "postpositioned_titles": null,
+      "orcid": "0000-0003-4206-1330",
+      "card_uri": "/person/420691337",
+      "picture_uri": null,
+      "main_phone_number": null,
+      "main_email": "anders.ericsson@tuwien.ac.at",
+      "other_emails": [],
+      "function_tiss_id": 1272,
+      "display_function": "Studiendekan",
+      "function_group_tiss_id": 1042,
+      "display_function_group": "Studiendekan_innen"
+    }
+  ],
+  "child_orgs_refs": []
+}
diff --git a/tests/test_tiss_integration.py b/tests/test_tiss_integration.py
new file mode 100644
index 0000000..7dd8aff
--- /dev/null
+++ b/tests/test_tiss_integration.py
@@ -0,0 +1,164 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2024 TU Wien.
+#
+# Invenio-Config-TUW is free software; you can redistribute it and/or modify
+# it under the terms of the MIT License; see LICENSE file for more details.
+
+"""Tests for the TISS integration."""
+
+import json
+import os
+import re
+
+import httpretty
+import pytest
+from invenio_access.permissions import system_identity
+from invenio_records_resources.proxies import current_service_registry
+
+from invenio_config_tuw.tasks import sync_names_from_tiss
+from invenio_config_tuw.tiss.models import OrgUnit
+from invenio_config_tuw.tiss.utils import _get_org_unit_dict, fetch_tiss_data
+
+
+@pytest.fixture()
+def names_vocabularies(app, db):
+    """Some name vocabulary entries."""
+    names_service = current_service_registry.get("names")
+
+    # this vocabulary entry will be updated, since the name changed
+    names_service.create(
+        system_identity,
+        {
+            "id": "0000-0003-1337-0425",
+            "name": "Science, Boss",
+            "given_name": "Boss",
+            "family_name": "Science",
+            "identifiers": [
+                {"identifier": "0000-0003-1337-0425", "scheme": "orcid"},
+            ],
+            "affiliations": [{"name": "TU Wien"}],
+        },
+    )
+
+    # this vocabulary entry will stay the same
+    names_service.create(
+        system_identity,
+        {
+            "id": "0000-0003-4206-1330",
+            "name": "Ericsson, Anders",
+            "given_name": "Anders",
+            "family_name": "Ericsson",
+            "identifiers": [
+                {"identifier": "0000-0003-4206-1330", "scheme": "orcid"},
+            ],
+            "affiliations": [{"name": "TU Wien"}],
+        },
+    )
+
+
+def mock_tiss_org_units():
+    """Mock the TISS API for organizational units."""
+    pattern = re.compile(
+        # the query string needs to be taken into account
+        r"^https://tiss.tuwien.ac.at/api/orgunit/v23/code/([eE]\d+[0-9-]*)(\?.*)?$"
+    )
+
+    def org_unit_callback(request, uri, response_headers):
+        """Mock the TISS reply with our example data."""
+        ou_code = pattern.match(uri).group(1).lower()
+        data_path = os.path.join(
+            os.path.dirname(__file__), "data", "tiss", f"{ou_code}.json"
+        )
+
+        with open(data_path, "r") as ou_file:
+            ou_data = json.load(ou_file)
+
+        # if we don't want the employees listed, we simply strip them out
+        persons = request.querystring.get("persons", [])
+        if not persons or persons[0] != "true":
+            ou_data.pop("manager", None)
+            ou_data.pop("employees", None)
+            for sub_ou in ou_data.get("child_org_refs", []):
+                sub_ou.pop("manager", None)
+
+        response_headers["Content-Type"] = "application/json"
+        return [200, response_headers, json.dumps(ou_data)]
+
+    httpretty.register_uri(
+        httpretty.GET,
+        pattern,
+        body=org_unit_callback,
+    )
+
+
+def mock_ror_api():
+    """Mock the response for TUW in the ROR API."""
+    data_path = os.path.join(os.path.dirname(__file__), "data", "ror-tuw.json")
+    with open(data_path, "r") as data_file:
+        ror_response = json.load(data_file)
+
+    httpretty.register_uri(
+        httpretty.GET,
+        "https://api.ror.org/organizations/04d836q62",
+        body=json.dumps(ror_response),
+        content_type="application/json",
+    )
+
+
+def test_fetching_real_tiss_data():
+    """Check if the real TISS API data looks as expected."""
+    # fmt: off
+    expected_common_keys = {
+        "card_uri", "code", "employees", "manager", "name_de",
+        "name_en", "number", "oid", "tiss_id", "type",
+    }
+    expected_e000_keys = expected_common_keys.union(
+        {"child_orgs_refs"}
+    )
+    expected_e05806_keys = expected_common_keys.union(
+        {"addresses", "parent_org_ref", "websites"}
+    )
+    # fmt: on
+
+    # E000 is the root node, and doesn't have employees itself
+    e000_data = _get_org_unit_dict("E000")
+    assert e000_data["tiss_id"] == 3000
+    assert set(e000_data.keys()) == expected_e000_keys
+    assert not e000_data["employees"]
+    e000_ou = OrgUnit.from_dict(e000_data)
+    assert not e000_ou.employees
+
+    # E058-06 is the CRDM, and has a few employees
+    crdm_data = _get_org_unit_dict("E058-06")
+    assert crdm_data["tiss_id"] == 6513
+    assert set(crdm_data.keys()) == expected_e05806_keys
+    assert crdm_data["employees"]
+    crdm_ou = OrgUnit.from_dict(crdm_data)
+    assert crdm_ou.employees
+
+
+@httpretty.activate
+def test_fetching_tiss_data():
+    """Test fetching the fake TISS data."""
+    mock_tiss_org_units()
+    org_units, employees = fetch_tiss_data()
+
+    # according to our test data, we expect 3 OUs with 4 employees in total
+    assert len(org_units) == 3
+    assert len(employees) == 4
+
+
+@httpretty.activate
+def test_sync_names_from_tiss(names_vocabularies, affiliations):
+    """Test the TISS names vocabulary synchronization task."""
+    mock_tiss_org_units()
+    mock_ror_api()
+
+    # our test data has 3 employees with ORCID identifiers:
+    # one will be updated due to a name change, one will stay the same
+    # and another one will be created fresh because it didn't exist before
+    results = sync_names_from_tiss()
+    assert results["created"] == 1
+    assert results["updated"] == 1
+    assert results["failed"] == 0
-- 
GitLab


From 92d0adf99598fa55eed62e9ac29c194b52ae4a69 Mon Sep 17 00:00:00 2001
From: Maximilian Moser <maximilian.moser@tuwien.ac.at>
Date: Fri, 3 Jan 2025 02:52:02 +0100
Subject: [PATCH 09/11] Add tests for the curation consent setting

---
 tests/conftest.py              | 66 ++++++++++++++++++++++++++++++++--
 tests/test_curation_consent.py | 54 ++++++++++++++++++++++++++++
 2 files changed, 118 insertions(+), 2 deletions(-)
 create mode 100644 tests/test_curation_consent.py

diff --git a/tests/conftest.py b/tests/conftest.py
index 58a9c75..ebebb97 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -13,24 +13,86 @@ fixtures are available.
 
 
 import pytest
+from flask_security.utils import hash_password, login_user
+from flask_webpackext.manifest import (
+    JinjaManifest,
+    JinjaManifestEntry,
+    JinjaManifestLoader,
+)
 from invenio_access.permissions import system_identity
-from invenio_app.factory import create_api
+from invenio_accounts.testutils import login_user_via_session
+from invenio_app.factory import create_app as create_invenio
 from invenio_records_resources.proxies import current_service_registry
 from invenio_vocabularies.proxies import current_service as vocab_svc
 
 
+#
+# Mock the webpack manifest to avoid having to compile the full assets.
+#
+class MockJinjaManifest(JinjaManifest):
+    """Mock manifest."""
+
+    def __getitem__(self, key):
+        """Get a manifest entry."""
+        return JinjaManifestEntry(key, [key])
+
+    def __getattr__(self, name):
+        """Get a manifest entry."""
+        return JinjaManifestEntry(name, [name])
+
+
+class MockManifestLoader(JinjaManifestLoader):
+    """Manifest loader creating a mocked manifest."""
+
+    def load(self, filepath):
+        """Load the manifest."""
+        return MockJinjaManifest()
+
+
 @pytest.fixture(scope="module")
 def create_app(instance_path):
     """Create test app."""
-    return create_api
+    return create_invenio
 
 
 @pytest.fixture(scope="module")
 def app_config(app_config):
     """Testing configuration."""
+    app_config["MAIL_SUPPRESS_SEND"] = True
+    app_config["WEBPACKEXT_MANIFEST_LOADER"] = MockManifestLoader
     return app_config
 
 
+@pytest.fixture()
+def users(app, db):
+    """Create example user."""
+    with db.session.begin_nested():
+        datastore = app.extensions["security"].datastore
+        user1 = datastore.create_user(
+            email="info@inveniosoftware.org",
+            password=hash_password("password"),
+            active=True,
+        )
+        user2 = datastore.create_user(
+            email="ser-testalot@inveniosoftware.org",
+            password=hash_password("beetlesmasher"),
+            active=True,
+        )
+
+    db.session.commit()
+    return [user1, user2]
+
+
+@pytest.fixture()
+def client_with_login(client, users):
+    """A test client for the app with a logged-in user."""
+    user = users[0]
+    login_user(user)
+    login_user_via_session(client, email=user.email)
+    client._user = user
+    return client
+
+
 @pytest.fixture()
 def affiliations(db):
     """Creates the required affiliations vocabulary for the tests."""
diff --git a/tests/test_curation_consent.py b/tests/test_curation_consent.py
new file mode 100644
index 0000000..6839c32
--- /dev/null
+++ b/tests/test_curation_consent.py
@@ -0,0 +1,54 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2025 TU Wien.
+#
+# Invenio-Config-TUW is free software; you can redistribute it and/or modify
+# it under the terms of the MIT License; see LICENSE file for more details.
+
+"""Tests for the per-user curation consent flag."""
+
+import pytest
+from invenio_accounts.proxies import current_datastore
+
+
+def test_curation_setting(users):
+    """Test setting the curation consent flag for users."""
+    user = users[0]
+    preferences = user.preferences or {}
+    assert "curation_consent" not in preferences
+
+    # try setting it to valid values
+    user.preferences = {**preferences, "curation_consent": False}
+    assert user.preferences.get("curation_consent") is False
+    user.preferences = {**preferences, "curation_consent": True}
+    assert user.preferences.get("curation_consent") is True
+
+    # setting it to an invalid value shouldn't work
+    with pytest.raises(ValueError):
+        user.preferences = {**user.preferences, "curation_consent": object()}
+
+
+def test_curation_preferences_form(client_with_login):
+    """Test the curation settings for a user."""
+    assert "curation_consent" not in (client_with_login._user.preferences or {})
+    response = client_with_login.get("/account/settings/curation/")
+    assert response.status_code == 200
+
+    # give consent for curation
+    response = client_with_login.post(
+        "/account/settings/curation/",
+        data={"preferences-curation-consent": "on", "submit": "preferences-curation-"},
+    )
+    user = current_datastore.get_user(client_with_login._user.email)
+    assert response.status_code == 200
+    assert user.preferences.get("curation_consent") is True
+
+    # withdraw consent for curation
+    # (omitting the checkbox value will evaluate it to `False`)
+    response = client_with_login.post(
+        "/account/settings/curation/",
+        data={"submit": "preferences-curation-"},
+    )
+    user = current_datastore.get_user(client_with_login._user.email)
+    assert response.status_code == 200
+    assert user.preferences.get("curation_consent") is False
-- 
GitLab


From 97c34ba43f9309fc84c3c894694a2740caf0c76a Mon Sep 17 00:00:00 2001
From: Maximilian Moser <maximilian.moser@tuwien.ac.at>
Date: Fri, 3 Jan 2025 02:54:17 +0100
Subject: [PATCH 10/11] Add further miscellaneous tests

---
 pyproject.toml     |  1 +
 tests/conftest.py  | 90 ++++++++++++++++++++++++++++++++++++++++++++++
 tests/test_misc.py | 79 ++++++++++++++++++++++++++++++++++++++++
 3 files changed, 170 insertions(+)
 create mode 100644 tests/test_misc.py

diff --git a/pyproject.toml b/pyproject.toml
index 5e87b3c..1f64e5d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -59,6 +59,7 @@ tests = [
     "httpretty>=1.1.4",
     "ipdb>=0.13.13",
     "ipython>=8.18.1",
+    "pytest-mock>=3.14.0",
 ]
 
 [project.urls]
diff --git a/tests/conftest.py b/tests/conftest.py
index ebebb97..add5891 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -11,6 +11,8 @@ See https://pytest-invenio.readthedocs.io/ for documentation on which test
 fixtures are available.
 """
 
+import os
+import shutil
 
 import pytest
 from flask_security.utils import hash_password, login_user
@@ -22,6 +24,8 @@ from flask_webpackext.manifest import (
 from invenio_access.permissions import system_identity
 from invenio_accounts.testutils import login_user_via_session
 from invenio_app.factory import create_app as create_invenio
+from invenio_files_rest.models import Location
+from invenio_rdm_records.proxies import current_rdm_records_service as records_service
 from invenio_records_resources.proxies import current_service_registry
 from invenio_vocabularies.proxies import current_service as vocab_svc
 
@@ -93,6 +97,51 @@ def client_with_login(client, users):
     return client
 
 
+@pytest.fixture()
+def files_loc(db):
+    """Creates app location for testing."""
+    loc_path = "testing_data_location"
+    if os.path.exists(loc_path):
+        shutil.rmtree(loc_path)
+
+    os.makedirs(loc_path)
+    loc = Location(name="local", uri=loc_path, default=True)
+    db.session.add(loc)
+    db.session.commit()
+    yield loc_path
+
+    os.rmdir(loc_path)
+
+
+@pytest.fixture()
+def resource_types(db):
+    """Creates the required resource type vocabulary for the tests."""
+    vocab_svc.create_type(system_identity, "resourcetypes", "rsrct")
+    vocab_svc.create(
+        system_identity,
+        {
+            "id": "dataset",
+            "icon": "table",
+            "props": {
+                "csl": "dataset",
+                "datacite_general": "Dataset",
+                "datacite_type": "",
+                "openaire_resourceType": "21",
+                "openaire_type": "dataset",
+                "eurepo": "info:eu-repo/semantics/other",
+                "schema.org": "https://schema.org/Dataset",
+                "subtype": "",
+                "type": "dataset",
+                "marc21_type": "dataset",
+                "marc21_subtype": "",
+            },
+            "title": {"en": "Dataset"},
+            "tags": ["depositable", "linkable"],
+            "type": "resourcetypes",
+        },
+    )
+
+
 @pytest.fixture()
 def affiliations(db):
     """Creates the required affiliations vocabulary for the tests."""
@@ -109,3 +158,44 @@ def affiliations(db):
             "title": {"de": "Technische Universit\xE4t Wien", "en": "TU Wien"},
         },
     )
+
+
+@pytest.fixture()
+def example_record(app, db, files_loc, users, resource_types):
+    """Example record."""
+    data = {
+        "access": {
+            "record": "public",
+            "files": "public",
+        },
+        "files": {
+            "enabled": False,
+        },
+        "metadata": {
+            "creators": [
+                {
+                    "person_or_org": {
+                        "family_name": "Darksouls",
+                        "given_name": "John",
+                        "type": "personal",
+                    }
+                },
+            ],
+            "publication_date": "2024-12-31",
+            "publisher": "TU Wien",
+            "resource_type": {"id": "dataset"},
+            "title": "Exciting dataset",
+        },
+    }
+
+    # create and publish the record
+    draft = records_service.create(system_identity, data)
+    record = records_service.publish(system_identity, draft.id)._obj
+
+    # make the first user the owner of the record
+    record.parent.access.owned_by = users[0]
+    record.parent.commit()
+    record.commit()
+    db.session.commit()
+
+    return record
diff --git a/tests/test_misc.py b/tests/test_misc.py
new file mode 100644
index 0000000..8d86bf8
--- /dev/null
+++ b/tests/test_misc.py
@@ -0,0 +1,79 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2025 TU Wien.
+#
+# Invenio-Config-TUW is free software; you can redistribute it and/or modify
+# it under the terms of the MIT License; see LICENSE file for more details.
+
+"""Miscellaneous tests that don't really belong anywhere else."""
+
+from logging.handlers import SMTPHandler
+
+from flask import g
+from invenio_db import db
+
+import invenio_config_tuw
+from invenio_config_tuw.startup import register_smtp_error_handler
+from invenio_config_tuw.tasks import send_publication_notification_email
+from invenio_config_tuw.users.utils import current_user_as_creator
+
+
+def test_send_publication_notification_email(example_record, mocker):
+    """Test (not really) sending an email about the publication of a record."""
+    mocker.patch("invenio_config_tuw.tasks.send_email")
+
+    send_publication_notification_email(example_record.pid.pid_value)
+
+    invenio_config_tuw.tasks.send_email.assert_called_once()
+
+
+def test_record_metadata_current_user_as_creator(client_with_login):
+    """Test the auto-generation of a "creator" entry for the current user."""
+    user = client_with_login._user
+    user.user_profile = {
+        "tiss_id": 274424,
+        "given_name": "Maximilian",
+        "family_name": "Moser",
+        "full_name": "Maximilian Moser",
+        "affiliations": "tuwien.ac.at",
+    }
+    db.session.commit()
+
+    expected_data = {
+        "affiliations": [{"id": "04d836q62", "name": "TU Wien"}],
+        "person_or_org": {
+            "family_name": user.user_profile["family_name"],
+            "given_name": user.user_profile["given_name"],
+            "identifiers": [],
+            "name": user.user_profile["full_name"],
+            "type": "personal",
+        },
+        "role": "contactperson",
+    }
+
+    # `current_user` requires a little rain dance to work
+    with client_with_login.application.test_request_context():
+        g._login_user = user
+        creator_data = current_user_as_creator()
+
+    assert [expected_data] == creator_data
+
+
+def test_register_smtp_error_handler(app):
+    """Test the registration of the SMTP handler for error logs."""
+    # the SMTP handler registration has a few configuration requirements
+    old_debug, old_testing = app.debug, app.testing
+    app.debug, app.testing = False, False
+    app.config["MAIL_SERVER"] = "smtp.example.com"
+    app.config["MAIL_ADMIN"] = "admin@example.com"
+
+    # check if the log handler registration works
+    old_num_handlers = len(app.logger.handlers)
+    assert not any([isinstance(h, SMTPHandler) for h in app.logger.handlers])
+    register_smtp_error_handler(app)
+    new_num_handlers = len(app.logger.handlers)
+    assert any([isinstance(h, SMTPHandler) for h in app.logger.handlers])
+    assert new_num_handlers == old_num_handlers + 1
+
+    # reset the previous debug/testing flags for the app
+    app.debug, app.testing = old_debug, old_testing
-- 
GitLab


From 16918dcda4888929d0c6b316d1a84a032575df65 Mon Sep 17 00:00:00 2001
From: Maximilian Moser <maximilian.moser@tuwien.ac.at>
Date: Fri, 3 Jan 2025 00:49:40 +0100
Subject: [PATCH 11/11] Add tests for the customized authentication workflow

---
 tests/conftest.py                       |  57 +++++++
 tests/data/keycloak/realm_info.json     |   7 +
 tests/data/keycloak/token_response.json |  11 ++
 tests/data/keycloak/user_info.json      |  17 ++
 tests/test_authentication.py            | 197 ++++++++++++++++++++++++
 5 files changed, 289 insertions(+)
 create mode 100644 tests/data/keycloak/realm_info.json
 create mode 100644 tests/data/keycloak/token_response.json
 create mode 100644 tests/data/keycloak/user_info.json
 create mode 100644 tests/test_authentication.py

diff --git a/tests/conftest.py b/tests/conftest.py
index add5891..e3e9f28 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -24,10 +24,14 @@ from flask_webpackext.manifest import (
 from invenio_access.permissions import system_identity
 from invenio_accounts.testutils import login_user_via_session
 from invenio_app.factory import create_app as create_invenio
+from invenio_communities.cache.cache import IdentityCache
 from invenio_files_rest.models import Location
 from invenio_rdm_records.proxies import current_rdm_records_service as records_service
 from invenio_records_resources.proxies import current_service_registry
 from invenio_vocabularies.proxies import current_service as vocab_svc
+from simplekv.memory import DictStore
+
+from invenio_config_tuw.auth.settings import TUWSSOSettingsHelper
 
 
 #
@@ -53,6 +57,34 @@ class MockManifestLoader(JinjaManifestLoader):
         return MockJinjaManifest()
 
 
+class DictIdentityCache(IdentityCache):
+    """Simple dictionary-based identity cache."""
+
+    def __init__(self):
+        """Constructor."""
+        self._dict = {}
+
+    def get(self, key):
+        """Get the cached object."""
+        return self._dict.get(key)
+
+    def set(self, key, value):
+        """Cache the object."""
+        self._dict[key] = value
+
+    def flush(self):
+        """Flush the cache."""
+        self._dict.clear()
+
+    def delete(self, key):
+        """Delete a key."""
+        self._dict.pop(key, None)
+
+    def append(self, key, value):
+        """Append a new value to a value list."""
+        self._dict[key] += value
+
+
 @pytest.fixture(scope="module")
 def create_app(instance_path):
     """Create test app."""
@@ -62,8 +94,33 @@ def create_app(instance_path):
 @pytest.fixture(scope="module")
 def app_config(app_config):
     """Testing configuration."""
+    helper = TUWSSOSettingsHelper(
+        title="Keycloak",
+        description="TUW-OIDC",
+        base_url="http://localhost:8080",
+        realm="test",
+    )
+    app_config["OAUTHCLIENT_REMOTE_APPS"] = {"keycloak": helper.remote_app}
+    app_config["OAUTHCLIENT_KEYCLOAK_REALM_URL"] = helper.realm_url
+    app_config["OAUTHCLIENT_KEYCLOAK_USER_INFO_URL"] = helper.user_info_url
+    app_config["OAUTHCLIENT_KEYCLOAK_VERIFY_AUD"] = False
+    app_config["OAUTHCLIENT_KEYCLOAK_VERIFY_EXP"] = False
+    app_config["OAUTHCLIENT_KEYCLOAK_AUD"] = "tudata"
+    app_config["KEYCLOAK_APP_CREDENTIALS"] = {
+        "consumer_key": "key",
+        "consumer_secret": "secret",
+    }
+
+    # set a dead simple in-memory session store, for the OIDC workflow
+    app_config["ACCOUNTS_SESSION_STORE_FACTORY"] = lambda app: DictStore({})
+    app_config["COMMUNITIES_IDENTITIES_CACHE_HANDLER"] = lambda app: DictIdentityCache()
+
+    # some further testing config
+    app_config["TESTING"] = True
+    app_config["WTF_CSRF_ENABLED"] = False
     app_config["MAIL_SUPPRESS_SEND"] = True
     app_config["WEBPACKEXT_MANIFEST_LOADER"] = MockManifestLoader
+
     return app_config
 
 
diff --git a/tests/data/keycloak/realm_info.json b/tests/data/keycloak/realm_info.json
new file mode 100644
index 0000000..4710673
--- /dev/null
+++ b/tests/data/keycloak/realm_info.json
@@ -0,0 +1,7 @@
+{
+  "realm": "test",
+  "public_key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA15A/QrSauz7PnB9JGYkDrHXPcqFaJjzm4kwsH+TbgZyvR8ULiLS6PLrHra10MhcGEyeF+9OBt96VZdG9BBuqGZPsHKcXYkVVlyZH5pbXDEmTm9Vm6SGoRgf+/morWlvg9EhhodAJPSn/s9ud6aCV4gRQGfxm4K7sVKy4NZ109pUB5CAojYHPOhcEEEBRsQHQzEi04swrqP5VfpbJlsRccuDCvPpqTxzAt+C/F5CIA7S+S117yLDKaMaVCkHVBxjK++0jKYJcmCCPCzh6WIF4drgV7I2hMDiJMJP/sUve7np7sjs8tBMNrNAcflpBBUQ0AIcDu7x8yn7YjVuC2NTL4wIDAQAB",
+  "token-service": "http://localhost:8080/auth/realms/test/protocol/openid-connect",
+  "account-service": "http://localhost:8080/auth/realms/test/account",
+  "tokens-not-before": 0
+}
diff --git a/tests/data/keycloak/token_response.json b/tests/data/keycloak/token_response.json
new file mode 100644
index 0000000..b3573e8
--- /dev/null
+++ b/tests/data/keycloak/token_response.json
@@ -0,0 +1,11 @@
+{
+  "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI4aVQ1cnZrYld0Mjc5UnFwd1pCakJYckFtZjcwMWd5RUJWU2cxc1hRTkxNIn0.eyJleHAiOjE3MzU4NTE2OTcsImlhdCI6MTczNTg1MTM5NywiYXV0aF90aW1lIjoxNzM1ODUxMzk3LCJqdGkiOiI5YmU2NWJiMy1lMjM5LTRiYTgtODY1ZC1lNDhhNTI2NzA3NDYiLCJpc3MiOiJodHRwczovL2F1dGgucmVzZWFyY2hkYXRhLnR1d2llbi5hYy5hdC9hdXRoL3JlYWxtcy90dWRhdGEtdGVzdCIsImF1ZCI6ImFjY291bnQiLCJzdWIiOiI1MzA5NWFmMy0xZjI1LTQxOGItYTZjMy0wOThhZThhYWIyODQiLCJ0eXAiOiJCZWFyZXIiLCJhenAiOiJ0dWRhdGEtdGVzdCIsInNpZCI6IjIzODBhNTdmLTNkZGYtNDE2Yy1hY2Y5LTcwY2EyNmE1NzdiYyIsImFjciI6IjEiLCJhbGxvd2VkLW9yaWdpbnMiOlsiaHR0cHM6Ly90ZXN0LnJlc2VhcmNoZGF0YS50dXdpZW4uYWMuYXQiXSwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbImRlZmF1bHQtcm9sZXMtdGVzdCIsIm9mZmxpbmVfYWNjZXNzIiwidW1hX2F1dGhvcml6YXRpb24iXX0sInJlc291cmNlX2FjY2VzcyI6eyJhY2NvdW50Ijp7InJvbGVzIjpbIm1hbmFnZS1hY2NvdW50IiwibWFuYWdlLWFjY291bnQtbGlua3MiLCJ2aWV3LXByb2ZpbGUiXX19LCJzY29wZSI6Im9wZW5pZCBwcm9maWxlIGVtYWlsIiwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJhZmZpbGlhdGlvbiI6WyJhbHVtQHR1d2llbi5hYy5hdCIsIm1lbWJlckB0dXdpZW4uYWMuYXQiLCJzdGFmZkB0dXdpZW4uYWMuYXQiLCJlbXBsb3llZUB0dXdpZW4uYWMuYXQiXSwibmFtZSI6Ik1heGltaWxpYW4gTW9zZXIiLCJzYW1sX2lkIjoiOWZiZTg1MjQxZjVkZWNlMjdhNDUwNjI4MDJjNGRhMWQ4NzZjNjhlNmE4Y2ZkYTRlNjBlYzE3YjI0YmQzZDgzMEB0dXdpZW4uYWMuYXQiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJtYXhpbWlsaWFuLm1vc2VyQHR1d2llbi5hYy5hdCIsImdpdmVuX25hbWUiOiJNYXhpbWlsaWFuIiwic2FtbF91aWQiOiIyNzQ0MjQiLCJmYW1pbHlfbmFtZSI6Ik1vc2VyIiwiZW1haWwiOiJtYXhpbWlsaWFuLm1vc2VyQHR1d2llbi5hYy5hdCJ9.blcGmZ0ExwvhzR_Ftqyii0m-MPp0vINsWcwq6jtky80oivG0589K1Fnlpc06lrHJ2TsbLX3V0Qqd3UqAtMKw5EN52ivF0_djz7j1qb6w2vTt-ar8Poc6z7nlS7WGX6Nq2e_nelob4o4ZfQ3evXbiSzVdQvPlK_kPWctfiBuOSbnhF2aLEAV7_TSHGHH-txdUX1GxxqS1JS-c4pQ0zNs2NFR7xRylkjPoykO6FWo6Qu-KHRgKTQ2kJjfekof5Q00oBuFw04Lvaqkk1Zq6xA-SK7NyrXBe5ih0CFO45egAzh3iseIQ1hfUoiriiAAHHTbejG0HytTfXls0R3HVJQLJwA",
+  "expires_in": 300,
+  "refresh_expires_in": 1800,
+  "refresh_token": "eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJhNTQwNGIyOS00MWM1LTRmZGMtYmNhMy05YTA1ZTA2ZWQwNjMifQ.eyJleHAiOjE3MzU4NTMxOTcsImlhdCI6MTczNTg1MTM5NywianRpIjoiZmNkNGY0YjgtNTA1Zi00NTI3LWEwMTMtOTNhMmY1YzU2NWUwIiwiaXNzIjoiaHR0cHM6Ly9hdXRoLnJlc2VhcmNoZGF0YS50dXdpZW4uYWMuYXQvYXV0aC9yZWFsbXMvdHVkYXRhLXRlc3QiLCJhdWQiOiJodHRwczovL2F1dGgucmVzZWFyY2hkYXRhLnR1d2llbi5hYy5hdC9hdXRoL3JlYWxtcy90dWRhdGEtdGVzdCIsInN1YiI6IjUzMDk1YWYzLTFmMjUtNDE4Yi1hNmMzLTA5OGFlOGFhYjI4NCIsInR5cCI6IlJlZnJlc2giLCJhenAiOiJ0dWRhdGEtdGVzdCIsInNpZCI6IjIzODBhNTdmLTNkZGYtNDE2Yy1hY2Y5LTcwY2EyNmE1NzdiYyIsInNjb3BlIjoib3BlbmlkIGFjciBiYXNpYyBwcm9maWxlIHdlYi1vcmlnaW5zIHJvbGVzIGVtYWlsIn0.wgf58cIu2SZN5O_VRCUNzZv5j80Mo--EaJx9-vg7fujTup1wpKJkFEospHZEuQT-u9gcstTyX_3xf1Q-i6UX3g",
+  "token_type": "Bearer",
+  "id_token": "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI4aVQ1cnZrYld0Mjc5UnFwd1pCakJYckFtZjcwMWd5RUJWU2cxc1hRTkxNIn0.eyJleHAiOjE3MzU4NTE2OTcsImlhdCI6MTczNTg1MTM5NywiYXV0aF90aW1lIjoxNzM1ODUxMzk3LCJqdGkiOiIzMTAzNGVkYS0xYjVkLTQwNDItODEzMC1jMmE3N2VkYzc5YjIiLCJpc3MiOiJodHRwczovL2F1dGgucmVzZWFyY2hkYXRhLnR1d2llbi5hYy5hdC9hdXRoL3JlYWxtcy90dWRhdGEtdGVzdCIsImF1ZCI6InR1ZGF0YS10ZXN0Iiwic3ViIjoiNTMwOTVhZjMtMWYyNS00MThiLWE2YzMtMDk4YWU4YWFiMjg0IiwidHlwIjoiSUQiLCJhenAiOiJ0dWRhdGEtdGVzdCIsInNpZCI6IjIzODBhNTdmLTNkZGYtNDE2Yy1hY2Y5LTcwY2EyNmE1NzdiYyIsImF0X2hhc2giOiJjNVF4VnZxTTlMMUpURmZwbW1ya3F3IiwiYWNyIjoiMSIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwiYWZmaWxpYXRpb24iOlsiYWx1bUB0dXdpZW4uYWMuYXQiLCJtZW1iZXJAdHV3aWVuLmFjLmF0Iiwic3RhZmZAdHV3aWVuLmFjLmF0IiwiZW1wbG95ZWVAdHV3aWVuLmFjLmF0Il0sIm5hbWUiOiJNYXhpbWlsaWFuIE1vc2VyIiwic2FtbF9pZCI6IjlmYmU4NTI0MWY1ZGVjZTI3YTQ1MDYyODAyYzRkYTFkODc2YzY4ZTZhOGNmZGE0ZTYwZWMxN2IyNGJkM2Q4MzBAdHV3aWVuLmFjLmF0IiwicHJlZmVycmVkX3VzZXJuYW1lIjoibWF4aW1pbGlhbi5tb3NlckB0dXdpZW4uYWMuYXQiLCJnaXZlbl9uYW1lIjoiTWF4aW1pbGlhbiIsInNhbWxfdWlkIjoiMjc0NDI0IiwiZmFtaWx5X25hbWUiOiJNb3NlciIsImVtYWlsIjoibWF4aW1pbGlhbi5tb3NlckB0dXdpZW4uYWMuYXQifQ.kcIS43BIJMturtDkYVhJaH5gEsbaPnNxRZCRGJ7ktyLXs4tkUOCDxxr2L7NywS3j2FaF11Ey2Rkss32SEn2TjJAnVI5Qh0EI1nteI9sZx-6aaogkU6CD5_oYv0J9xhhuUT582aMhqGxnUeOdcMnqBwCcQdwubnwLHjHq9Ihst8-U_h_JIJ9eAVzaswqkPiL8jvKrDkSSvg55F_nQC7T96mQxGdBkE73mi78YbTi5hT-cyFBnLqORAPzjssCHlgHW5ytJVszKEyQlMhW13tM_5qzuaD-srHZm4Yr3P3DnaMTu7E0Cm8S1oyjp4qrszI-tvmNgCyynLTEV-PK-nFwnNA",
+  "not-before-policy": 0,
+  "session_state": "2380a57f-3ddf-416c-acf9-70ca26a577bc",
+  "scope": "openid profile email"
+}
diff --git a/tests/data/keycloak/user_info.json b/tests/data/keycloak/user_info.json
new file mode 100644
index 0000000..e091694
--- /dev/null
+++ b/tests/data/keycloak/user_info.json
@@ -0,0 +1,17 @@
+{
+  "sub": "53095af3-1f25-418b-a6c3-098ae8aab284",
+  "email_verified": false,
+  "affiliation": [
+    "alum@tuwien.ac.at",
+    "member@tuwien.ac.at",
+    "staff@tuwien.ac.at",
+    "employee@tuwien.ac.at"
+  ],
+  "name": "Maximilian Moser",
+  "saml_id": "9fbe85241f5dece27a45062802c4da1d876c68e6a8cfda4e60ec17b24bd3d830@tuwien.ac.at",
+  "preferred_username": "maximilian.moser@tuwien.ac.at",
+  "given_name": "Maximilian",
+  "saml_uid": "274424",
+  "family_name": "Moser",
+  "email": "maximilian.moser@tuwien.ac.at"
+}
diff --git a/tests/test_authentication.py b/tests/test_authentication.py
new file mode 100644
index 0000000..f836a1c
--- /dev/null
+++ b/tests/test_authentication.py
@@ -0,0 +1,197 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2025 TU Wien.
+#
+# Invenio-Config-TUW is free software; you can redistribute it and/or modify
+# it under the terms of the MIT License; see LICENSE file for more details.
+
+"""Tests for the authentication workflow."""
+
+
+import json
+import os
+
+import httpretty
+import pytest
+from flask import url_for
+from flask_login.utils import _create_identifier
+from flask_oauthlib.client import OAuthResponse
+from invenio_accounts.proxies import current_datastore
+from invenio_db import db
+from invenio_oauthclient.views.client import serializer
+
+
+def get_state(app):
+    """Get state, like in the Invenio-OAuthClient tests."""
+    return serializer.dumps(
+        {
+            "app": app,
+            "sid": _create_identifier(),
+            "next": None,
+        }
+    )
+
+
+def mock_keycloak(app_config, realm_info_dict, token_response_dict, user_info):
+    """Mock a running Keycloak instance."""
+    keycloak_settings = app_config["OAUTHCLIENT_REMOTE_APPS"]["keycloak"]
+
+    httpretty.register_uri(
+        httpretty.POST,
+        keycloak_settings["params"]["access_token_url"],
+        body=json.dumps(token_response_dict),
+        content_type="application/json",
+    )
+    httpretty.register_uri(
+        httpretty.GET,
+        app_config["OAUTHCLIENT_KEYCLOAK_USER_INFO_URL"],
+        body=json.dumps(user_info.data),
+        content_type="application/json",
+    )
+    httpretty.register_uri(
+        httpretty.GET,
+        app_config["OAUTHCLIENT_KEYCLOAK_REALM_URL"],
+        body=json.dumps(realm_info_dict),
+        content_type="application/json",
+    )
+
+
+@pytest.fixture
+def token_response_dict():
+    """Keycloak access token."""
+    root = os.path.dirname(__file__)
+    path = os.path.join(root, "data", "keycloak", "token_response.json")
+    with open(path, "r") as data_file:
+        return json.load(data_file)
+
+
+@pytest.fixture
+def user_info():
+    """Keycloak user info."""
+    root = os.path.dirname(__file__)
+    path = os.path.join(root, "data", "keycloak", "user_info.json")
+    with open(path, "r") as data_file:
+        response = json.load(data_file)
+
+    return OAuthResponse(
+        resp=None,
+        content=json.dumps(response),
+        content_type="application/json",
+    )
+
+
+@pytest.fixture
+def realm_info_dict():
+    """Keycloak realm info."""
+    root = os.path.dirname(__file__)
+    path = os.path.join(root, "data", "keycloak", "realm_info.json")
+    with open(path, "r") as data_file:
+        return json.load(data_file)
+
+
+@httpretty.activate
+def test_authentication_workflow(app, realm_info_dict, token_response_dict, user_info):
+    """Test the customized authentication workflow with Keycloak."""
+    mock_keycloak(app.config, realm_info_dict, token_response_dict, user_info)
+
+    # ---------------------------------------- #
+    # part 1: initial login, with registration #
+    # ---------------------------------------- #
+    with app.test_client() as client:
+        # initiate the login process (this is a required step)...
+        resp = client.get(
+            url_for("invenio_oauthclient.login", remote_app="keycloak"),
+        )
+        assert resp.status_code == 302
+
+        # ... now, the user has authorized the login request and is redirected back...
+        resp = client.get(
+            url_for(
+                "invenio_oauthclient.authorized",
+                remote_app="keycloak",
+                code="test",
+                state=get_state("keycloak"),
+            )
+        )
+        assert resp.status_code == 302
+        assert resp.location == "/oauth/signup/keycloak/"
+        registration_form = resp.location
+
+        # to complete the auth process, the user has to fill out the registration form
+        resp = client.get(registration_form)
+        assert resp.status_code == 200
+        assert 'name="terms_of_use"' in resp.text
+        assert 'name="curation_consent"' in resp.text
+
+        # note: the email & username don't matter here, they're taken from the tokens
+        form_data = {
+            "email": "nonsense@example.com",
+            "username": "nobody",
+            "terms_of_use": True,
+            "curation_consent": True,
+        }
+        resp = client.post(registration_form, data=form_data)
+        assert resp.status_code == 302
+        assert resp.location == "/"
+
+        # after successful registration, the user should be available
+        user = current_datastore.get_user(user_info.data["email"])
+        assert user
+        assert user.email == "maximilian.moser@tuwien.ac.at"
+        assert user.username == "maximilian-moser"
+        assert user.preferences["curation_consent"] is True
+        assert user.user_profile["given_name"] == "Maximilian"
+        assert user.user_profile["family_name"] == "Moser"
+        assert user.user_profile["full_name"] == "Maximilian Moser"
+        assert user.user_profile["affiliations"] == "tuwien.ac.at"
+        assert user.user_profile["tiss_id"] == 274424
+
+        # now let's log out again
+        client.get(url_for("security.logout"))
+
+    # --------------------------------------------------------------- #
+    # part 2: somehow letting the IdP and local user data drift apart #
+    # --------------------------------------------------------------- #
+    # update the user's information
+    user = current_datastore.get_user(user_info.data["email"])
+    user.user_profile = {
+        **user.user_profile,
+        "given_name": "Max J.",
+        "family_name": "Moser",
+        "full_name": "Max J. Moser",
+    }
+    db.session.commit()
+
+    # verify that the information actually changed
+    user = current_datastore.get_user(user_info.data["email"])
+    assert user.user_profile["given_name"] == "Max J."
+    assert user.user_profile["family_name"] == "Moser"
+    assert user.user_profile["full_name"] == "Max J. Moser"
+
+    # -------------------------------------------------------------------- #
+    # part 3: logging in again, having the user info updated automatically #
+    # -------------------------------------------------------------------- #
+    with app.test_client() as client:
+        # initiate the login process (this is a required step)...
+        resp = client.get(
+            url_for("invenio_oauthclient.login", remote_app="keycloak"),
+        )
+        assert resp.status_code == 302
+
+        # ... now, the user has authorized the login request and is redirected back...
+        resp = client.get(
+            url_for(
+                "invenio_oauthclient.authorized",
+                remote_app="keycloak",
+                code="test",
+                state=get_state("keycloak"),
+            )
+        )
+        assert resp.status_code == 302
+        assert resp.location == "/account/settings/linkedaccounts/"
+
+        # the user information should be updated according to the token
+        user = current_datastore.get_user(user_info.data["email"])
+        assert user.user_profile["given_name"] == "Maximilian"
+        assert user.user_profile["family_name"] == "Moser"
+        assert user.user_profile["full_name"] == "Maximilian Moser"
-- 
GitLab