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