import base64 from types import SimpleNamespace from unittest.mock import patch from allauth.socialaccount.models import SocialAccount, SocialApp, SocialLogin from allauth.socialaccount.signals import social_account_updated from django.contrib.sessions.backends.db import SessionStore from django.test import RequestFactory, TestCase from files.tests import create_account, fixture_path from identity_providers.models import ( IdentityProviderGlobalRole, IdentityProviderGroupRole, IdentityProviderUserLog, ) from rbac.models import RBACGroup, RBACMembership from saml_auth.adapter import ( SAMLAccountAdapter, add_user_logo, handle_role_mapping, handle_saml_logs_save, perform_user_actions, ) from saml_auth.models import SAMLConfiguration from users.models import User PROVIDER_ID = "adapter-idp" def make_user(username, **kwargs): return create_account(username=username, email=f"{username}@example.com", password="saml-adapter-password", **kwargs) def make_social_app(provider_id=PROVIDER_ID): return SocialApp.objects.create(provider="saml", provider_id=provider_id, name=f"{provider_id} name", client_id=provider_id) def make_configuration(social_app, **overrides): fields = { "social_app": social_app, "sso_url": "https://idp.example.org/sso", "slo_url": "https://idp.example.org/slo", "sp_metadata_url": "https://sp.example.org/saml/metadata", "idp_id": f"https://idp.example.org/{social_app.provider_id}", "idp_cert": "dummy-cert", "uid": "uid", "groups": "isMemberOf", "role": "affiliation", } fields.update(overrides) return SAMLConfiguration.objects.create(**fields) def png_base64(): with open(fixture_path("test_image.png"), "rb") as fp: return base64.b64encode(fp.read()).decode() class PopulateUserTest(TestCase): def setUp(self): self.adapter = SAMLAccountAdapter() def sociallogin(self, uid): return SimpleNamespace(user=User(), account=SimpleNamespace(uid=uid), data=None) def test_username_keeps_only_reversible_characters(self): sociallogin = self.sociallogin("jane doe/éx+1@uni.edu") user = self.adapter.populate_user(None, sociallogin, {}) self.assertEqual(user.username, "jane_doe__x_1@uni.edu") def test_username_is_truncated_to_field_length(self): user = self.adapter.populate_user(None, self.sociallogin("a" * 300), {}) self.assertEqual(len(user.username), 150) def test_missing_uid_leaves_username_empty(self): user = self.adapter.populate_user(None, self.sociallogin(None), {}) self.assertEqual(user.username, "") def test_name_fields_are_copied_and_data_is_kept_on_sociallogin(self): sociallogin = self.sociallogin("jdoe") data = {"name": "Jane Doe", "first_name": "Jane", "last_name": "", "email": "jane@example.com"} user = self.adapter.populate_user(None, sociallogin, data) self.assertEqual((user.name, user.first_name, user.last_name), ("Jane Doe", "Jane", "")) self.assertIs(sociallogin.data, data) def test_signup_is_always_open(self): self.assertTrue(self.adapter.is_open_for_signup(None, None)) class SaveUserTest(TestCase): def test_new_user_is_saved_and_mapped_to_groups_from_response(self): social_app = make_social_app() make_configuration(social_app) group = RBACGroup.objects.create(name="Physics", uid="physics", identity_provider=social_app) user = User(username="new_saml_user", email="new_saml_user@example.com") account = SocialAccount(provider=PROVIDER_ID, uid="new_saml_user", extra_data={"isMemberOf": ["physics"], "affiliation": ["member"]}) sociallogin = SocialLogin(user=user, account=account) request = RequestFactory().get("/") request.session = SessionStore() saved = SAMLAccountAdapter().save_user(request, sociallogin) self.assertIsNotNone(saved.pk) self.assertFalse(saved.has_usable_password()) self.assertTrue(SocialAccount.objects.filter(user=saved, uid="new_saml_user").exists()) self.assertTrue(RBACMembership.objects.filter(user=saved, rbac_group=group, role="member").exists()) class SocialAccountUpdatedTest(TestCase): def test_signal_refreshes_changed_profile_fields(self): user = make_user("saml_updated_user", name="Old Name") account = SimpleNamespace(provider="unknown-provider", extra_data={}) sociallogin = SimpleNamespace(user=user, account=account, data={"name": "New Name", "email": "fresh@example.com", "first_name": ""}) social_account_updated.send(sender=SocialLogin, request=None, sociallogin=sociallogin) user.refresh_from_db() self.assertEqual(user.name, "New Name") self.assertEqual(user.email, "fresh@example.com") class PerformUserActionsTest(TestCase): @classmethod def setUpTestData(cls): cls.social_app = make_social_app() def account(self, extra_data): return SimpleNamespace(provider=PROVIDER_ID, extra_data=extra_data) def test_unchanged_fields_are_not_saved(self): user = make_user("saml_same_user", name="Same") with patch.object(User, "save") as save: perform_user_actions(user, self.account({}), {"name": "Same", "email": user.email}) save.assert_not_called() def test_without_configuration_no_mapping_or_log_happens(self): user = make_user("saml_noconf_user") RBACGroup.objects.create(name="Unused", uid="unused", identity_provider=self.social_app) perform_user_actions(user, self.account({"isMemberOf": ["unused"]})) self.assertFalse(RBACMembership.objects.filter(user=user).exists()) self.assertFalse(IdentityProviderUserLog.objects.exists()) def test_with_configuration_response_is_logged_without_photo(self): make_configuration(self.social_app) user = make_user("saml_logged_user") perform_user_actions(user, self.account({"uid": ["saml_logged_user"], "jpegPhoto": [png_base64()]})) log = IdentityProviderUserLog.objects.get(user=user, identity_provider=self.social_app) self.assertIn("saml_logged_user", log.logs) self.assertNotIn("jpegPhoto", log.logs) self.assertIn("SAML Log - saml_logged_user", str(log)) def test_logging_can_be_disabled_on_configuration(self): make_configuration(self.social_app, save_saml_response_logs=False) user = make_user("saml_unlogged_user") perform_user_actions(user, self.account({"uid": ["x"]})) self.assertFalse(IdentityProviderUserLog.objects.exists()) def test_unknown_provider_is_tolerated(self): user = make_user("saml_unknown_provider_user") result = perform_user_actions(user, SimpleNamespace(provider="nobody", extra_data={})) self.assertIs(result, user) def test_logs_save_strips_photo_from_stored_response(self): user = make_user("saml_strip_user") extra_data = {"mail": ["a@example.com"], "jpegPhoto": ["abc"]} self.assertTrue(handle_saml_logs_save(user, extra_data, self.social_app)) self.assertNotIn("jpegPhoto", extra_data) self.assertEqual(IdentityProviderUserLog.objects.get(user=user).logs, str({"mail": ["a@example.com"]})) class AddUserLogoTest(TestCase): @classmethod def setUpTestData(cls): cls.social_app = make_social_app() def test_default_photo_attribute_sets_logo_for_user_without_one(self): user = make_user("saml_logo_user") self.assertEqual(user.logo.name, "userlogos/user.jpg") add_user_logo(user, {"jpegPhoto": [png_base64()]}) user.refresh_from_db() self.assertNotEqual(user.logo.name, "userlogos/user.jpg") self.assertTrue(user.logo.name.startswith("userlogos/")) def test_configured_attribute_name_is_used(self): configuration = make_configuration(self.social_app, user_logo="thumbnailPhoto") user = make_user("saml_custom_logo_user") add_user_logo(user, {"jpegPhoto": [png_base64()]}, configuration) user.refresh_from_db() self.assertEqual(user.logo.name, "userlogos/user.jpg") add_user_logo(user, {"thumbnailPhoto": [png_base64()]}, configuration) user.refresh_from_db() self.assertNotEqual(user.logo.name, "userlogos/user.jpg") def test_existing_custom_logo_is_not_overwritten(self): user = make_user("saml_has_logo_user") User.objects.filter(pk=user.pk).update(logo="userlogos/custom.jpg") user.refresh_from_db() add_user_logo(user, {"jpegPhoto": [png_base64()]}) user.refresh_from_db() self.assertEqual(user.logo.name, "userlogos/custom.jpg") def test_undecodable_photo_is_logged_and_ignored(self): user = make_user("saml_bad_logo_user") with self.assertLogs(level="ERROR"): self.assertTrue(add_user_logo(user, {"jpegPhoto": ["not base64!"]})) user.refresh_from_db() self.assertEqual(user.logo.name, "userlogos/user.jpg") class HandleRoleMappingTest(TestCase): @classmethod def setUpTestData(cls): cls.social_app = make_social_app() cls.other_app = make_social_app("adapter-other-idp") cls.physics = RBACGroup.objects.create(name="Physics", uid="physics", identity_provider=cls.social_app) cls.maths = RBACGroup.objects.create(name="Maths", uid="maths", identity_provider=cls.social_app) cls.foreign = RBACGroup.objects.create(name="Physics", uid="physics", identity_provider=cls.other_app) def setUp(self): self.configuration = make_configuration(self.social_app) self.user = make_user("saml_role_user") def roles(self): return set(RBACMembership.objects.filter(user=self.user).values_list("rbac_group__uid", "rbac_group__identity_provider__provider_id", "role")) def test_without_configuration_nothing_happens(self): self.assertFalse(handle_role_mapping(self.user, {"isMemberOf": ["physics"]}, self.social_app, None)) self.assertEqual(self.roles(), set()) def test_groups_of_this_provider_get_member_role_by_default(self): self.assertTrue(handle_role_mapping(self.user, {"isMemberOf": ["physics", "unknown"]}, self.social_app, self.configuration)) self.assertEqual(self.roles(), {("physics", PROVIDER_ID, "member")}) def test_role_attribute_naming_a_group_role_is_used_directly(self): handle_role_mapping(self.user, {"isMemberOf": ["physics"], "affiliation": ["manager", "member"]}, self.social_app, self.configuration) self.assertEqual(self.roles(), {("physics", PROVIDER_ID, "manager")}) def test_group_role_mapping_translates_idp_role(self): IdentityProviderGroupRole.objects.create(identity_provider=self.social_app, name="faculty", map_to="contributor") handle_role_mapping(self.user, {"isMemberOf": ["physics", "maths"], "affiliation": ["faculty"]}, self.social_app, self.configuration) self.assertEqual(self.roles(), {("physics", PROVIDER_ID, "contributor"), ("maths", PROVIDER_ID, "contributor")}) def test_unmapped_role_falls_back_to_member(self): handle_role_mapping(self.user, {"isMemberOf": ["physics"], "affiliation": "alumnus"}, self.social_app, self.configuration) self.assertEqual(self.roles(), {("physics", PROVIDER_ID, "member")}) def test_global_role_mapping_updates_user_flags(self): IdentityProviderGlobalRole.objects.create(identity_provider=self.social_app, name="staff", map_to="editor") handle_role_mapping(self.user, {"affiliation": ["staff"]}, self.social_app, self.configuration) self.user.refresh_from_db() self.assertTrue(self.user.is_editor) self.assertFalse(self.user.is_superuser) def test_existing_membership_role_is_updated(self): RBACMembership.objects.create(user=self.user, rbac_group=self.physics, role="member") handle_role_mapping(self.user, {"isMemberOf": ["physics"], "affiliation": ["contributor"]}, self.social_app, self.configuration) self.assertEqual(self.roles(), {("physics", PROVIDER_ID, "contributor")}) def test_groups_missing_from_response_are_kept_by_default(self): RBACMembership.objects.create(user=self.user, rbac_group=self.maths, role="member") handle_role_mapping(self.user, {"isMemberOf": ["physics"]}, self.social_app, self.configuration) self.assertEqual(self.roles(), {("physics", PROVIDER_ID, "member"), ("maths", PROVIDER_ID, "member")}) def test_remove_from_groups_drops_only_this_providers_missing_groups(self): self.configuration.remove_from_groups = True self.configuration.save() RBACMembership.objects.create(user=self.user, rbac_group=self.maths, role="member") RBACMembership.objects.create(user=self.user, rbac_group=self.foreign, role="member") handle_role_mapping(self.user, {"isMemberOf": ["physics"]}, self.social_app, self.configuration) self.assertEqual(self.roles(), {("physics", PROVIDER_ID, "member"), ("physics", "adapter-other-idp", "member")}) def test_errors_while_reading_role_mappings_still_assign_groups(self): with patch.object(User, "set_role_from_mapping", side_effect=RuntimeError("boom")): IdentityProviderGlobalRole.objects.create(identity_provider=self.social_app, name="manager", map_to="manager") with self.assertLogs(level="ERROR"): handle_role_mapping(self.user, {"isMemberOf": ["physics"], "affiliation": ["manager"]}, self.social_app, self.configuration) self.assertEqual(self.roles(), {("physics", PROVIDER_ID, "manager")})