diff --git a/CHANGES.rst b/CHANGES.rst index 063a8333..dd53635e 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -3,6 +3,11 @@ All notable changes to this project will be documented in this file. The format is based on `Keep a Changelog `_, and this project adheres to `Semantic Versioning `_. +Changed +******* + +- Model attributes cardinality is closer to SCIM model. :pr:`155` + [0.0.34] - 2023-10-02 ===================== diff --git a/canaille/app/__init__.py b/canaille/app/__init__.py index 5c5182b8..846929de 100644 --- a/canaille/app/__init__.py +++ b/canaille/app/__init__.py @@ -58,3 +58,11 @@ def validate_uri(value): re.IGNORECASE, ) return re.match(regex, value) is not None + + +class classproperty: + def __init__(self, f): + self.f = f + + def __get__(self, obj, owner): + return self.f(owner) diff --git a/canaille/backends/ldap/ldapobject.py b/canaille/backends/ldap/ldapobject.py index 8b3884b6..c52fbf00 100644 --- a/canaille/backends/ldap/ldapobject.py +++ b/canaille/backends/ldap/ldapobject.py @@ -6,6 +6,7 @@ import ldap.filter from canaille.backends.models import Model from .backend import Backend +from .utils import cardinalize_attribute from .utils import ldap_to_python from .utils import listify from .utils import python_to_ldap @@ -109,7 +110,7 @@ class LDAPObject(Model, metaclass=LDAPObjectMetaclass): base = None root_dn = None rdn_attribute = None - attributes = None + attribute_map = None ldap_object_class = None def __init__(self, dn=None, **kwargs): @@ -121,8 +122,7 @@ class LDAPObject(Model, metaclass=LDAPObjectMetaclass): setattr(self, name, value) def __repr__(self): - reverse_attributes = {v: k for k, v in (self.attributes or {}).items()} - attribute_name = reverse_attributes.get(self.rdn_attribute, self.rdn_attribute) + attribute_name = self.ldap_attribute_to_python(self.rdn_attribute) return ( f"<{self.__class__.__name__} {attribute_name}={self.rdn_value}>" if self.rdn_attribute @@ -130,29 +130,30 @@ class LDAPObject(Model, metaclass=LDAPObjectMetaclass): ) def __eq__(self, other): + ldap_attributes = self.may() + self.must() if not ( isinstance(other, self.__class__) and self.may() == other.may() and self.must() == other.must() and all( - hasattr(self, attr) == hasattr(other, attr) - for attr in self.may() + self.must() + self.has_ldap_attribute(attr) == other.has_ldap_attribute(attr) + for attr in ldap_attributes ) ): return False self_attributes = python_attrs_to_ldap( { - attr: getattr(self, attr) - for attr in self.may() + self.must() - if hasattr(self, attr) + attr: self.get_ldap_attribute(attr) + for attr in ldap_attributes + if self.has_ldap_attribute(attr) } ) other_attributes = python_attrs_to_ldap( { - attr: getattr(other, attr) - for attr in self.may() + self.must() - if hasattr(self, attr) + attr: other.get_ldap_attribute(attr) + for attr in ldap_attributes + if other.has_ldap_attribute(attr) } ) return self_attributes == other_attributes @@ -161,17 +162,40 @@ class LDAPObject(Model, metaclass=LDAPObjectMetaclass): return hash(self.id) def __getattr__(self, name): - name = self.attributes.get(name, name) - - if name not in self.ldap_object_attributes(): + if name not in self.attributes: return super().__getattribute__(name) - single_value = self.ldap_object_attributes()[name].single_value + ldap_name = self.python_attribute_to_ldap(name) + + if ldap_name == "dn": + return self.dn_for(self.rdn_value) + + python_single_value = "List" not in str(self.__annotations__[name]) + ldap_value = self.get_ldap_attribute(ldap_name) + return cardinalize_attribute(python_single_value, ldap_value) + + def __setattr__(self, name, value): + if name not in self.attributes: + super().__setattr__(name, value) + + ldap_name = self.python_attribute_to_ldap(name) + self.set_ldap_attribute(ldap_name, value) + + def __delattr__(self, name): + ldap_name = self.python_attribute_to_ldap(name) + self.delete_ldap_attribute(ldap_name) + + def has_ldap_attribute(self, name): + return name in self.ldap_object_attributes() and ( + name in self.changes or name in self.state + ) + + def get_ldap_attribute(self, name): if name in self.changes: - return self.changes[name][0] if single_value else self.changes[name] + return self.changes[name] if not self.state.get(name): - return None if single_value else [] + return None # Lazy conversion from ldap format to python format if any(isinstance(value, bytes) for value in self.state[name]): @@ -180,35 +204,23 @@ class LDAPObject(Model, metaclass=LDAPObjectMetaclass): ldap_to_python(value, syntax) for value in self.state[name] ] - if single_value: - return self.state.get(name)[0] - else: - return [value for value in self.state.get(name) if value is not None] + return self.state.get(name) - def __setattr__(self, name, value): - if self.attributes: - name = self.attributes.get(name, name) + def set_ldap_attribute(self, name, value): + if name not in self.ldap_object_attributes(): + return - if name in self.ldap_object_attributes(): - value = listify(value) - self.changes[name] = value + value = listify(value) + self.changes[name] = value - else: - super().__setattr__(name, value) - - def __delattr__(self, name): - name = self.attributes.get(name, name) + def delete_ldap_attribute(self, name): self.changes[name] = [None] @property def rdn_value(self): - value = getattr(self, self.rdn_attribute) + value = self.get_ldap_attribute(self.rdn_attribute) return (value[0] if isinstance(value, list) else value).strip() - @property - def dn(self): - return self.dn_for(self.rdn_value) - @classmethod def dn_for(cls, rdn): return f"{cls.rdn_attribute}={ldap.dn.escape_dn_chars(rdn)},{cls.base},{cls.root_dn}" @@ -317,7 +329,7 @@ class LDAPObject(Model, metaclass=LDAPObjectMetaclass): arg_filter = "" kwargs = python_attrs_to_ldap( { - (cls.attributes or {}).get(name, name): values + cls.python_attribute_to_ldap(name): values for name, values in kwargs.items() }, encode=False, @@ -350,7 +362,7 @@ class LDAPObject(Model, metaclass=LDAPObjectMetaclass): def fuzzy(cls, query, attributes=None, **kwargs): query = ldap.filter.escape_filter_chars(query) attributes = attributes or cls.may() + cls.must() - attributes = [cls.attributes.get(name, name) for name in attributes] + attributes = [cls.python_attribute_to_ldap(name) for name in attributes] filter = ( "(|" + "".join(f"({attribute}=*{query}*)" for attribute in attributes) + ")" ) @@ -380,6 +392,15 @@ class LDAPObject(Model, metaclass=LDAPObjectMetaclass): cls._may = list(set(cls._may)) cls._must = list(set(cls._must)) + @classmethod + def ldap_attribute_to_python(cls, name): + reverse_attribute_map = {v: k for k, v in (cls.attribute_map or {}).items()} + return reverse_attribute_map.get(name, name) + + @classmethod + def python_attribute_to_ldap(cls, name): + return cls.attribute_map.get(name, name) if cls.attribute_map else None + def reload(self, conn=None): conn = conn or Backend.get().connection result = conn.search_s(self.id, ldap.SCOPE_SUBTREE, None, ["+", "*"]) @@ -389,7 +410,7 @@ class LDAPObject(Model, metaclass=LDAPObjectMetaclass): def save(self, conn=None): conn = conn or Backend.get().connection - setattr(self, "objectClass", self.ldap_object_class) + self.set_ldap_attribute("objectClass", self.ldap_object_class) # Object already exists in the LDAP database if self.exists: @@ -429,10 +450,6 @@ class LDAPObject(Model, metaclass=LDAPObjectMetaclass): self.state = {**self.state, **self.changes} self.changes = {} - def update(self, **kwargs): - for k, v in kwargs.items(): - self.__setattr__(k, v) - def delete(self, conn=None): conn = conn or Backend.get().connection conn.delete_s(self.id) diff --git a/canaille/backends/ldap/models.py b/canaille/backends/ldap/models.py index 4cc0bedf..894db63a 100644 --- a/canaille/backends/ldap/models.py +++ b/canaille/backends/ldap/models.py @@ -15,7 +15,7 @@ class User(canaille.core.models.User, LDAPObject): DEFAULT_FILTER = "(|(uid={{ login }})(mail={{ login }}))" DEFAULT_RDN = "cn" - attributes = { + attribute_map = { "id": "dn", "user_name": "uid", "password": "userPassword", @@ -66,7 +66,7 @@ class User(canaille.core.models.User, LDAPObject): filter_["groups"] = Group.dn_for(filter_["groups"]) base = "".join( - f"({cls.attributes.get(key, key)}={value})" + f"({cls.python_attribute_to_ldap(key)}={value})" for key, value in filter_.items() ) return f"(&{base})" if len(filter_) > 1 else base @@ -147,7 +147,7 @@ class User(canaille.core.models.User, LDAPObject): self.load_permissions() def save(self, *args, **kwargs): - group_attr = self.attributes.get("groups", "groups") + group_attr = self.python_attribute_to_ldap("groups") new_groups = self.changes.get(group_attr) if not new_groups: return super().save(*args, **kwargs) @@ -194,7 +194,7 @@ class Group(canaille.core.models.Group, LDAPObject): DEFAULT_NAME_ATTRIBUTE = "cn" DEFAULT_USER_FILTER = "member={user.id}" - attributes = { + attribute_map = { "id": "dn", "display_name": "cn", "members": "member", @@ -243,9 +243,8 @@ class Client(canaille.oidc.models.Client, LDAPObject): "software_version": "oauthSoftwareVersion", } - attributes = { + attribute_map = { "id": "dn", - "description": "description", "preconsent": "oauthPreconsent", # post_logout_redirect_uris is not yet supported by authlib "post_logout_redirect_uris": "oauthPostLogoutRedirectURI", @@ -263,10 +262,9 @@ class AuthorizationCode(canaille.oidc.models.AuthorizationCode, LDAPObject): ldap_object_class = ["oauthAuthorizationCode"] base = "ou=authorizations,ou=oauth" rdn_attribute = "oauthAuthorizationCodeID" - attributes = { + attribute_map = { "id": "dn", "authorization_code_id": "oauthAuthorizationCodeID", - "description": "description", "code": "oauthCode", "client": "oauthClient", "subject": "oauthSubject", @@ -290,11 +288,10 @@ class Token(canaille.oidc.models.Token, LDAPObject): ldap_object_class = ["oauthToken"] base = "ou=tokens,ou=oauth" rdn_attribute = "oauthTokenID" - attributes = { + attribute_map = { "id": "dn", "token_id": "oauthTokenID", "access_token": "oauthAccessToken", - "description": "description", "client": "oauthClient", "subject": "oauthSubject", "type": "oauthTokenType", @@ -315,7 +312,7 @@ class Consent(canaille.oidc.models.Consent, LDAPObject): ldap_object_class = ["oauthConsent"] base = "ou=consents,ou=oauth" rdn_attribute = "cn" - attributes = { + attribute_map = { "id": "dn", "consent_id": "cn", "subject": "oauthSubject", diff --git a/canaille/backends/ldap/utils.py b/canaille/backends/ldap/utils.py index 68fded32..bd0c2549 100644 --- a/canaille/backends/ldap/utils.py +++ b/canaille/backends/ldap/utils.py @@ -85,3 +85,13 @@ def python_to_ldap(value, syntax, encode=True): def listify(value): return value if isinstance(value, list) else [value] + + +def cardinalize_attribute(python_unique, value): + if not value: + return None if python_unique else [] + + if python_unique: + return value[0] + + return [v for v in value if v is not None] diff --git a/canaille/backends/memory/models.py b/canaille/backends/memory/models.py index 5561ead9..a05edc5e 100644 --- a/canaille/backends/memory/models.py +++ b/canaille/backends/memory/models.py @@ -90,6 +90,7 @@ class MemoryModel(Model): self.index()[self.id] = copy.deepcopy(self.state) # update the index for each attribute + print(self.attributes) for attribute in self.attributes: attribute_values = listify(getattr(self, attribute)) for value in attribute_values: @@ -161,10 +162,6 @@ class MemoryModel(Model): # update the id index del self.index()[self.id] - def update(self, **kwargs): - for attribute, value in kwargs.items(): - setattr(self, attribute, value) - def reload(self): self.state = self.__class__.get(id=self.id).state self.cache = {} @@ -193,11 +190,11 @@ class MemoryModel(Model): ] values = [value for value in values if value] - if name in self.unique_attributes: + unique_attribute = "List" not in str(self.__annotations__[name]) + if unique_attribute: return values[0] if values else None else: return values or [] - raise AttributeError() raise AttributeError() @@ -222,43 +219,13 @@ class MemoryModel(Model): except KeyError: pass + @property + def identifier(self): + return getattr(self, self.identifier_attribute) + class User(canaille.core.models.User, MemoryModel): - attributes = [ - "id", - "user_name", - "password", - "preferred_language", - "family_name", - "given_name", - "formatted_name", - "display_name", - "emails", - "phone_numbers", - "formatted_address", - "street", - "postal_code", - "locality", - "region", - "photo", - "profile_url", - "employee_number", - "department", - "title", - "organization", - "last_modified", - "groups", - "lock_date", - ] identifier_attribute = "user_name" - unique_attributes = [ - "id", - "display_name", - "employee_number", - "preferred_language", - "last_modified", - "lock_date", - ] model_attributes = { "groups": ("Group", "members"), } @@ -297,7 +264,7 @@ class User(canaille.core.models.User, MemoryModel): ).id return all( - value in getattr(self, attribute, None) + getattr(self, attribute) and value in getattr(self, attribute) for attribute, value in filter.items() ) @@ -307,10 +274,6 @@ class User(canaille.core.models.User, MemoryModel): def get_from_login(cls, login=None, **kwargs): return User.get(user_name=login) - @property - def identifier(self): - return getattr(self, self.identifier_attribute)[0] - def has_password(self): return bool(self.password) @@ -335,178 +298,39 @@ class User(canaille.core.models.User, MemoryModel): class Group(canaille.core.models.Group, MemoryModel): - attributes = [ - "id", - "display_name", - "members", - "description", - ] - unique_attributes = ["id", "display_name"] model_attributes = { "members": ("User", "groups"), } identifier_attribute = "display_name" - @property - def identifier(self): - return getattr(self, self.identifier_attribute) - class Client(canaille.oidc.models.Client, MemoryModel): - attributes = [ - "id", - "description", - "preconsent", - "post_logout_redirect_uris", - "audience", - "client_id", - "client_secret", - "client_id_issued_at", - "client_secret_expires_at", - "client_name", - "contacts", - "client_uri", - "redirect_uris", - "logo_uri", - "grant_types", - "response_types", - "scope", - "tos_uri", - "policy_uri", - "jwks_uri", - "jwk", - "token_endpoint_auth_method", - "software_id", - "software_version", - ] identifier_attribute = "client_id" - unique_attributes = [ - "id", - "preconsent", - "client_id", - "client_secret", - "client_id_issued_at", - "client_secret_expires_at", - "client_name", - "client_uri", - "logo_uri", - "tos_uri", - "policy_uri", - "jwks_uri", - "jwk", - "token_endpoint_auth_method", - "software_id", - "software_version", - ] model_attributes = { "audience": ("Client", None), } - @property - def identifier(self): - return getattr(self, self.identifier_attribute) - class AuthorizationCode(canaille.oidc.models.AuthorizationCode, MemoryModel): - attributes = [ - "id", - "authorization_code_id", - "description", - "code", - "client", - "subject", - "redirect_uri", - "response_type", - "scope", - "nonce", - "issue_date", - "lifetime", - "challenge", - "challenge_method", - "revokation_date", - ] identifier_attribute = "authorization_code_id" - unique_attributes = [ - "id", - "authorization_code_id", - "code", - "client", - "subject", - "redirect_uri", - "issue_date", - "lifetime", - "challenge", - "challenge_method", - "revokation_date", - "nonce", - ] model_attributes = { "client": ("Client", None), "subject": ("User", None), } - @property - def identifier(self): - return getattr(self, self.identifier_attribute) - class Token(canaille.oidc.models.Token, MemoryModel): - attributes = [ - "id", - "token_id", - "access_token", - "description", - "client", - "subject", - "type", - "refresh_token", - "scope", - "issue_date", - "lifetime", - "revokation_date", - "audience", - ] identifier_attribute = "token_id" - unique_attributes = [ - "id", - "token_id", - "subject", - "issue_date", - "lifetime", - "access_token", - "refresh_token", - "revokation_date", - "client", - "type", - ] model_attributes = { "client": ("Client", None), "subject": ("User", None), "audience": ("Client", None), } - @property - def identifier(self): - return getattr(self, self.identifier_attribute) - class Consent(canaille.oidc.models.Consent, MemoryModel): - attributes = [ - "id", - "consent_id", - "subject", - "client", - "scope", - "issue_date", - "revokation_date", - ] identifier_attribute = "consent_id" - unique_attributes = ["id", "subject", "client", "issue_date", "revokation_date"] model_attributes = { "client": ("Client", None), "subject": ("User", None), } - - @property - def identifier(self): - return getattr(self, self.identifier_attribute)[0] diff --git a/canaille/backends/models.py b/canaille/backends/models.py index 91e1f621..9f0a678f 100644 --- a/canaille/backends/models.py +++ b/canaille/backends/models.py @@ -1,8 +1,19 @@ +from collections import ChainMap + +from canaille.app import classproperty + + class Model: """ Model abstract class. """ + @classproperty + def attributes(cls): + return ChainMap( + *(c.__annotations__ for c in cls.__mro__ if "__annotations__" in c.__dict__) + ) + @classmethod def query(cls, **kwargs): """ @@ -74,7 +85,8 @@ class Model: >>> user.first_name Jane """ - raise NotImplementedError() + for attribute, value in kwargs.items(): + setattr(self, attribute, value) def reload(self): """ diff --git a/canaille/config.sample.toml b/canaille/config.sample.toml index b2b7b8f0..63fd95a5 100644 --- a/canaille/config.sample.toml +++ b/canaille/config.sample.toml @@ -224,17 +224,17 @@ WRITE = [ # User objectClass. # {attribute} will be replaced by the user ldap attribute value. # Default values fits inetOrgPerson. -# SUB = "{{ user.user_name[0] }}" -# NAME = "{{ user.formatted_name[0] }}" +# SUB = "{{ user.user_name }}" +# NAME = "{{ user.formatted_name }}" # PHONE_NUMBER = "{{ user.phone_numbers[0] }}" # EMAIL = "{{ user.preferred_email }}" -# GIVEN_NAME = "{{ user.given_name[0] }}" -# FAMILY_NAME = "{{ user.family_name[0] }}" +# GIVEN_NAME = "{{ user.given_name }}" +# FAMILY_NAME = "{{ user.family_name }}" # PREFERRED_USERNAME = "{{ user.display_name }}" # LOCALE = "{{ user.preferred_language }}" -# ADDRESS = "{{ user.formatted_address[0] }}" +# ADDRESS = "{{ user.formatted_address }}" # PICTURE = "{% if user.photo %}{{ url_for('core.account.photo', user=user, field='photo', _external=True) }}{% endif %}" -# WEBSITE = "{{ user.profile_url[0] }}" +# WEBSITE = "{{ user.profile_url }}" # The SMTP server options. If not set, mail related features such as # user invitations, and password reset emails, will be disabled. diff --git a/canaille/core/account.py b/canaille/core/account.py index 761f2ef2..d0268360 100644 --- a/canaille/core/account.py +++ b/canaille/core/account.py @@ -438,7 +438,7 @@ def profile_creation(user): def profile_create(current_app, form): user = models.User() for attribute in form: - if attribute.name in user.attributes: + if attribute.name in models.User.attributes: if isinstance(attribute.data, FileStorage): data = attribute.data.stream.read() else: @@ -449,8 +449,8 @@ def profile_create(current_app, form): if "photo" in form and form["photo_delete"].data: del user.photo - given_name = user.given_name[0] if user.given_name else "" - family_name = user.family_name[0] if user.family_name else "" + given_name = user.given_name if user.given_name else "" + family_name = user.family_name if user.family_name else "" user.formatted_name = [f"{given_name} {family_name}".strip()] user.save() @@ -802,7 +802,7 @@ def profile_delete(user, edited_user): flash( _( "The user %(user)s has been sucessfuly deleted", - user=edited_user.formatted_name[0], + user=edited_user.formatted_name, ), "success", ) @@ -818,7 +818,7 @@ def profile_delete(user, edited_user): def impersonate(user, puppet): login_user(puppet) flash( - _("Connection successful. Welcome %(user)s", user=puppet.formatted_name[0]), + _("Connection successful. Welcome %(user)s", user=puppet.formatted_name), "success", ) return redirect(url_for("core.account.index")) @@ -837,11 +837,11 @@ def photo(user, field): if request.if_none_match and etag in request.if_none_match: return "", 304 - photos = getattr(user, field) - if not photos: + photo = getattr(user, field) + if not photo: abort(404) - stream = io.BytesIO(photos[0]) + stream = io.BytesIO(photo) return send_file( stream, mimetype="image/jpeg", last_modified=user.last_modified, etag=etag ) diff --git a/canaille/core/auth.py b/canaille/core/auth.py index 53a93402..113fee4d 100644 --- a/canaille/core/auth.py +++ b/canaille/core/auth.py @@ -89,7 +89,7 @@ def password(): del session["attempt_login"] login_user(user) flash( - _("Connection successful. Welcome %(user)s", user=user.formatted_name[0]), + _("Connection successful. Welcome %(user)s", user=user.formatted_name), "success", ) return redirect(session.pop("redirect-after-login", url_for("core.account.index"))) @@ -103,7 +103,7 @@ def logout(): flash( _( "You have been disconnected. See you next time %(user)s", - user=user.formatted_name[0], + user=user.formatted_name, ), "success", ) @@ -169,7 +169,7 @@ def forgotten(): _( "The user '%(user)s' does not have permissions to update their password. " "We cannot send a password reset email.", - user=user.formatted_name[0], + user=user.formatted_name, ), "error", ) diff --git a/canaille/core/forms.py b/canaille/core/forms.py index 8c8c3646..dd0afd09 100644 --- a/canaille/core/forms.py +++ b/canaille/core/forms.py @@ -22,7 +22,7 @@ MINIMUM_PASSWORD_LENGTH = 8 def unique_login(form, field): if models.User.get_from_login(field.data) and ( - not getattr(form, "user", None) or form.user.user_name[0] != field.data + not getattr(form, "user", None) or form.user.user_name != field.data ): raise wtforms.ValidationError( _("The login '{login}' already exists").format(login=field.data) diff --git a/canaille/core/templates/modals/delete-account.html b/canaille/core/templates/modals/delete-account.html index 4ba5e64b..fc28b300 100644 --- a/canaille/core/templates/modals/delete-account.html +++ b/canaille/core/templates/modals/delete-account.html @@ -11,7 +11,7 @@

{% if user != edited_user %} - {% trans user_name=(edited_user.formatted_name[0] or edited_user.identifier) %} + {% trans user_name=(edited_user.formatted_name or edited_user.identifier) %} Are you sure you want to delete the account of {{ user_name }}? This action is unrevokable and all the data about this user will be removed. {% endtrans %} {% else %} diff --git a/canaille/core/templates/modals/lock-account.html b/canaille/core/templates/modals/lock-account.html index 4913fab7..d7a91b5a 100644 --- a/canaille/core/templates/modals/lock-account.html +++ b/canaille/core/templates/modals/lock-account.html @@ -11,7 +11,7 @@

{% if user != edited_user %} - {% trans user_name=(edited_user.formatted_name[0] or edited_user.identifier) %} + {% trans user_name=(edited_user.formatted_name or edited_user.identifier) %} Are you sure you want to lock the account of {{ user_name }} ? The user won't be able to login until their account is unlocked. {% endtrans %} {% else %} diff --git a/canaille/core/templates/partial/users.html b/canaille/core/templates/partial/users.html index 3dead2df..214c24e9 100644 --- a/canaille/core/templates/partial/users.html +++ b/canaille/core/templates/partial/users.html @@ -39,7 +39,7 @@ {% if watched_user.user_name %} - {{ watched_user.user_name[0] }} + {{ watched_user.user_name }} {% else %} {{ watched_user.identifier }} {% endif %} @@ -47,7 +47,7 @@ {% endif %} {% if user.can_read("family_name") or user.can_read("given_name") %} - {{ watched_user.formatted_name[0] }} + {{ watched_user.formatted_name }} {% endif %} {% if user.can_read("emails") %} {{ watched_user.preferred_email }} diff --git a/canaille/oidc/basemodels.py b/canaille/oidc/basemodels.py index 1dd4cd4d..169295a2 100644 --- a/canaille/oidc/basemodels.py +++ b/canaille/oidc/basemodels.py @@ -27,7 +27,7 @@ class Client(Model): logo_uri: Optional[str] grant_types: List[str] response_types: List[str] - scope: Optional[str] + scope: List[str] tos_uri: Optional[str] policy_uri: Optional[str] jwks_uri: Optional[str] @@ -49,7 +49,7 @@ class AuthorizationCode(Model): subject: User redirect_uri: Optional[str] response_type: Optional[str] - scope: Optional[str] + scope: List[str] nonce: Optional[str] issue_date: datetime.datetime lifetime: int @@ -70,7 +70,7 @@ class Token(Model): subject: User type: str refresh_token: str - scope: str + scope: List[str] issue_date: datetime.datetime lifetime: int revokation_date: datetime.datetime @@ -86,7 +86,7 @@ class Consent(Model): consent_id: str subject: User client: "Client" - scope: str + scope: List[str] issue_date: datetime.datetime revokation_date: datetime.datetime diff --git a/canaille/oidc/endpoints.py b/canaille/oidc/endpoints.py index e16c7819..4ab56c85 100644 --- a/canaille/oidc/endpoints.py +++ b/canaille/oidc/endpoints.py @@ -242,7 +242,7 @@ def end_session(): if ( not data.get("id_token_hint") - or (data.get("logout_hint") and data["logout_hint"] != user.user_name[0]) + or (data.get("logout_hint") and data["logout_hint"] != user.user_name) ) and not session.get("end_session_confirmation"): session["end_session_data"] = data return render_template("logout.html", form=form, client=client, menu=False) @@ -282,7 +282,7 @@ def end_session(): if client: valid_uris.extend(client.post_logout_redirect_uris or []) - if user.user_name[0] != id_token["sub"] and not session.get( + if user.user_name != id_token["sub"] and not session.get( "end_session_confirmation" ): session["end_session_data"] = data diff --git a/canaille/oidc/oauth.py b/canaille/oidc/oauth.py index 8b781f9c..973bb271 100644 --- a/canaille/oidc/oauth.py +++ b/canaille/oidc/oauth.py @@ -37,17 +37,17 @@ DEFAULT_JWT_ALG = "RS256" DEFAULT_JWT_EXP = 3600 AUTHORIZATION_CODE_LIFETIME = 84400 DEFAULT_JWT_MAPPING = { - "SUB": "{{ user.user_name[0] }}", - "NAME": "{% if user.formatted_name %}{{ user.formatted_name[0] }}{% endif %}", + "SUB": "{{ user.user_name }}", + "NAME": "{% if user.formatted_name %}{{ user.formatted_name }}{% endif %}", "PHONE_NUMBER": "{% if user.phone_numbers %}{{ user.phone_numbers[0] }}{% endif %}", "EMAIL": "{% if user.preferred_email %}{{ user.preferred_email }}{% endif %}", - "GIVEN_NAME": "{% if user.given_name %}{{ user.given_name[0] }}{% endif %}", - "FAMILY_NAME": "{% if user.family_name %}{{ user.family_name[0] }}{% endif %}", + "GIVEN_NAME": "{% if user.given_name %}{{ user.given_name }}{% endif %}", + "FAMILY_NAME": "{% if user.family_name %}{{ user.family_name }}{% endif %}", "PREFERRED_USERNAME": "{% if user.display_name %}{{ user.display_name }}{% endif %}", "LOCALE": "{% if user.preferred_language %}{{ user.preferred_language }}{% endif %}", - "ADDRESS": "{% if user.formatted_address %}{{ user.formatted_address[0] }}{% endif %}", + "ADDRESS": "{% if user.formatted_address %}{{ user.formatted_address }}{% endif %}", "PICTURE": "{% if user.photo %}{{ url_for('core.account.photo', user=user, field='photo', _external=True) }}{% endif %}", - "WEBSITE": "{% if user.profile_url %}{{ user.profile_url[0] }}{% endif %}", + "WEBSITE": "{% if user.profile_url %}{{ user.profile_url }}{% endif %}", } @@ -332,9 +332,9 @@ class IntrospectionEndpoint(_IntrospectionEndpoint): "active": True, "client_id": token.client.client_id, "token_type": token.type, - "username": token.subject.formatted_name[0], + "username": token.subject.formatted_name, "scope": token.get_scope(), - "sub": token.subject.user_name[0], + "sub": token.subject.user_name, "aud": audience, "iss": get_issuer(), "exp": token.get_expires_at(), diff --git a/canaille/oidc/templates/authorize.html b/canaille/oidc/templates/authorize.html index 15f044be..9ca271f6 100644 --- a/canaille/oidc/templates/authorize.html +++ b/canaille/oidc/templates/authorize.html @@ -32,7 +32,7 @@

- {{ gettext('You are logged in as %(name)s', name=user.formatted_name[0]) }} + {{ gettext('You are logged in as %(name)s', name=user.formatted_name) }}
diff --git a/canaille/oidc/templates/partial/authorization_list.html b/canaille/oidc/templates/partial/authorization_list.html index f072a563..c8391d43 100644 --- a/canaille/oidc/templates/partial/authorization_list.html +++ b/canaille/oidc/templates/partial/authorization_list.html @@ -15,7 +15,7 @@ {{ authorization.client.client_id }} - {{ authorization.subject.user_name[0] }} + {{ authorization.subject.user_name }} {{ authorization.issue_date }} diff --git a/canaille/oidc/templates/partial/token_list.html b/canaille/oidc/templates/partial/token_list.html index 209885cc..2fdf3b7f 100644 --- a/canaille/oidc/templates/partial/token_list.html +++ b/canaille/oidc/templates/partial/token_list.html @@ -23,7 +23,7 @@ - {{ token.subject.user_name[0] }} + {{ token.subject.user_name }} {{ token.issue_date }} diff --git a/demo/conf-docker/canaille-ldap.toml b/demo/conf-docker/canaille-ldap.toml index fb0dc5d6..3a3e4354 100644 --- a/demo/conf-docker/canaille-ldap.toml +++ b/demo/conf-docker/canaille-ldap.toml @@ -230,17 +230,17 @@ DYNAMIC_CLIENT_REGISTRATION_TOKENS = [ # User objectClass. # {attribute} will be replaced by the user ldap attribute value. # Default values fits inetOrgPerson. -# SUB = "{{ user.user_name[0] }}" -# NAME = "{{ user.formatted_name[0] }}" +# SUB = "{{ user.user_name }}" +# NAME = "{{ user.formatted_name }}" # PHONE_NUMBER = "{{ user.phone_numbers[0] }}" # EMAIL = "{{ user.preferred_email }}" -# GIVEN_NAME = "{{ user.given_name[0] }}" -# FAMILY_NAME = "{{ user.family_name[0] }}" +# GIVEN_NAME = "{{ user.given_name }}" +# FAMILY_NAME = "{{ user.family_name }}" # PREFERRED_USERNAME = "{{ user.display_name }}" # LOCALE = "{{ user.preferred_language }}" -# ADDRESS = "{{ user.formatted_address[0] }}" +# ADDRESS = "{{ user.formatted_address }}" # PICTURE = "{% if user.photo %}{{ url_for('core.account.photo', user=user, field='photo', _external=True) }}{% endif %}" -# WEBSITE = "{{ user.profile_url[0] }}" +# WEBSITE = "{{ user.profile_url }}" # The SMTP server options. If not set, mail related features such as # user invitations, and password reset emails, will be disabled. diff --git a/demo/conf-docker/canaille-memory.toml b/demo/conf-docker/canaille-memory.toml index d71abf38..87757100 100644 --- a/demo/conf-docker/canaille-memory.toml +++ b/demo/conf-docker/canaille-memory.toml @@ -230,17 +230,17 @@ DYNAMIC_CLIENT_REGISTRATION_TOKENS = [ # User objectClass. # {attribute} will be replaced by the user ldap attribute value. # Default values fits inetOrgPerson. -# SUB = "{{ user.user_name[0] }}" -# NAME = "{{ user.formatted_name[0] }}" +# SUB = "{{ user.user_name }}" +# NAME = "{{ user.formatted_name }}" # PHONE_NUMBER = "{{ user.phone_numbers[0] }}" # EMAIL = "{{ user.preferred_email }}" -# GIVEN_NAME = "{{ user.given_name[0] }}" -# FAMILY_NAME = "{{ user.family_name[0] }}" +# GIVEN_NAME = "{{ user.given_name }}" +# FAMILY_NAME = "{{ user.family_name }}" # PREFERRED_USERNAME = "{{ user.display_name }}" # LOCALE = "{{ user.preferred_language }}" -# ADDRESS = "{{ user.formatted_address[0] }}" +# ADDRESS = "{{ user.formatted_address }}" # PICTURE = "{% if user.photo %}{{ url_for('core.account.photo', user=user, field='photo', _external=True) }}{% endif %}" -# WEBSITE = "{{ user.profile_url[0] }}" +# WEBSITE = "{{ user.profile_url }}" # The SMTP server options. If not set, mail related features such as # user invitations, and password reset emails, will be disabled. diff --git a/demo/conf/canaille-ldap.toml b/demo/conf/canaille-ldap.toml index e0ba611a..3896db0d 100644 --- a/demo/conf/canaille-ldap.toml +++ b/demo/conf/canaille-ldap.toml @@ -234,17 +234,17 @@ DYNAMIC_CLIENT_REGISTRATION_TOKENS = [ # User objectClass. # {attribute} will be replaced by the user ldap attribute value. # Default values fits inetOrgPerson. -# SUB = "{{ user.user_name[0] }}" -# NAME = "{{ user.formatted_name[0] }}" +# SUB = "{{ user.user_name }}" +# NAME = "{{ user.formatted_name }}" # PHONE_NUMBER = "{{ user.phone_numbers[0] }}" # EMAIL = "{{ user.preferred_email }}" -# GIVEN_NAME = "{{ user.given_name[0] }}" -# FAMILY_NAME = "{{ user.family_name[0] }}" +# GIVEN_NAME = "{{ user.given_name }}" +# FAMILY_NAME = "{{ user.family_name }}" # PREFERRED_USERNAME = "{{ user.display_name }}" # LOCALE = "{{ user.preferred_language }}" -# ADDRESS = "{{ user.formatted_address[0] }}" +# ADDRESS = "{{ user.formatted_address }}" # PICTURE = "{% if user.photo %}{{ url_for('core.account.photo', user=user, field='photo', _external=True) }}{% endif %}" -# WEBSITE = "{{ user.profile_url[0] }}" +# WEBSITE = "{{ user.profile_url }}" # The SMTP server options. If not set, mail related features such as # user invitations, and password reset emails, will be disabled. diff --git a/demo/conf/canaille-memory.toml b/demo/conf/canaille-memory.toml index f5591a86..3a762294 100644 --- a/demo/conf/canaille-memory.toml +++ b/demo/conf/canaille-memory.toml @@ -234,17 +234,17 @@ DYNAMIC_CLIENT_REGISTRATION_TOKENS = [ # User objectClass. # {attribute} will be replaced by the user ldap attribute value. # Default values fits inetOrgPerson. -# SUB = "{{ user.user_name[0] }}" -# NAME = "{{ user.formatted_name[0] }}" +# SUB = "{{ user.user_name }}" +# NAME = "{{ user.formatted_name }}" # PHONE_NUMBER = "{{ user.phone_numbers[0] }}" # EMAIL = "{{ user.preferred_email }}" -# GIVEN_NAME = "{{ user.given_name[0] }}" -# FAMILY_NAME = "{{ user.family_name[0] }}" +# GIVEN_NAME = "{{ user.given_name }}" +# FAMILY_NAME = "{{ user.family_name }}" # PREFERRED_USERNAME = "{{ user.display_name }}" # LOCALE = "{{ user.preferred_language }}" -# ADDRESS = "{{ user.formatted_address[0] }}" +# ADDRESS = "{{ user.formatted_address }}" # PICTURE = "{% if user.photo %}{{ url_for('core.account.photo', user=user, field='photo', _external=True) }}{% endif %}" -# WEBSITE = "{{ user.profile_url[0] }}" +# WEBSITE = "{{ user.profile_url }}" # The SMTP server options. If not set, mail related features such as # user invitations, and password reset emails, will be disabled. diff --git a/doc/configuration.rst b/doc/configuration.rst index 961c0e06..a2536376 100644 --- a/doc/configuration.rst +++ b/doc/configuration.rst @@ -253,7 +253,7 @@ A mapping where keys are JWT claims, and values are LDAP user object attributes. Attributes are rendered using jinja2, and can use a ``user`` variable. :SUB: - *Optional.* Defaults to ``{{ user.user_name[0] }}`` + *Optional.* Defaults to ``{{ user.user_name }}`` :NAME: *Optional.* Defaults to ``{{ user.cn[0] }}`` @@ -265,10 +265,10 @@ Attributes are rendered using jinja2, and can use a ``user`` variable. *Optional.* Defaults to ``{{ user.mail[0] }}`` :GIVEN_NAME: - *Optional.* Defaults to ``{{ user.given_name[0] }}`` + *Optional.* Defaults to ``{{ user.given_name }}`` :FAMILY_NAME: - *Optional.* Defaults to ``{{ user.family_name[0] }}`` + *Optional.* Defaults to ``{{ user.family_name }}`` :PREFERRED_USERNAME: *Optional.* Defaults to ``{{ user.display_name[0] }}`` @@ -280,10 +280,10 @@ Attributes are rendered using jinja2, and can use a ``user`` variable. *Optional.* Defaults to ``{{ user.address[0] }}`` :PICTURE: - *Optional.* Defaults to ``{% if user.photo %}{{ url_for('core.account.photo', user_name=user.user_name[0], field='photo', _external=True) }}{% endif %}`` + *Optional.* Defaults to ``{% if user.photo %}{{ url_for('core.account.photo', user_name=user.user_name, field='photo', _external=True) }}{% endif %}`` :WEBSITE: - *Optional.* Defaults to ``{{ user.profile_url[0] }}`` + *Optional.* Defaults to ``{{ user.profile_url }}`` SMTP diff --git a/tests/backends/ldap/test_utils.py b/tests/backends/ldap/test_utils.py index 7b6a99f7..c7944a34 100644 --- a/tests/backends/ldap/test_utils.py +++ b/tests/backends/ldap/test_utils.py @@ -14,6 +14,7 @@ from canaille.backends.ldap.utils import python_to_ldap from canaille.backends.ldap.utils import Syntax +# TODO: tester le changement de cardinalité des attributs def test_object_creation(app, backend): user = models.User( formatted_name="Doe", # leading space @@ -45,13 +46,14 @@ def test_dn_when_leading_space_in_id_attribute(testclient, backend): ) user.save() - assert ldap.dn.is_dn(user.dn) - assert ldap.dn.dn2str(ldap.dn.str2dn(user.dn)) == user.dn - assert user.dn == "uid=user,ou=users,dc=mydomain,dc=tld" + dn = user.id + assert dn == "uid=user,ou=users,dc=mydomain,dc=tld" + assert ldap.dn.is_dn(dn) + assert ldap.dn.dn2str(ldap.dn.str2dn(dn)) == dn assert user == models.User.get(user.identifier) assert user == models.User.get(user_name=user.identifier) - assert user == models.User.get(id=user.dn) + assert user == models.User.get(id=dn) user.delete() @@ -65,13 +67,14 @@ def test_special_chars_in_rdn(testclient, backend): ) user.save() - assert ldap.dn.is_dn(user.dn) - assert ldap.dn.dn2str(ldap.dn.str2dn(user.dn)) == user.dn - assert user.dn == "uid=\\#user,ou=users,dc=mydomain,dc=tld" + dn = user.id + assert ldap.dn.is_dn(dn) + assert ldap.dn.dn2str(ldap.dn.str2dn(dn)) == dn + assert dn == "uid=\\#user,ou=users,dc=mydomain,dc=tld" assert user == models.User.get(user.identifier) assert user == models.User.get(user_name=user.identifier) - assert user == models.User.get(id=user.dn) + assert user == models.User.get(id=dn) user.delete() @@ -179,10 +182,11 @@ def test_operational_attribute_conversion(backend): def test_guess_object_from_dn(backend, testclient, foo_group): foo_group.members = [foo_group] foo_group.save() - g = LDAPObject.get(id=foo_group.dn) + dn = foo_group.id + g = LDAPObject.get(id=dn) assert isinstance(g, models.Group) assert g == foo_group - assert g.cn == foo_group.cn + assert g.display_name == foo_group.display_name ou = LDAPObject.get(id=f"{models.Group.base},{models.Group.root_dn}") assert isinstance(ou, LDAPObject) @@ -195,8 +199,10 @@ def test_object_class_update(backend, testclient): user1 = models.User(cn="foo1", sn="bar1", user_name="baz1") user1.save() - assert user1.objectClass == ["inetOrgPerson"] - assert models.User.get(id=user1.id).objectClass == ["inetOrgPerson"] + assert user1.get_ldap_attribute("objectClass") == ["inetOrgPerson"] + assert models.User.get(id=user1.id).get_ldap_attribute("objectClass") == [ + "inetOrgPerson" + ] testclient.app.config["BACKENDS"]["LDAP"]["USER_CLASS"] = [ "inetOrgPerson", @@ -207,18 +213,24 @@ def test_object_class_update(backend, testclient): user2 = models.User(cn="foo2", sn="bar2", user_name="baz2") user2.save() - assert user2.objectClass == ["inetOrgPerson", "extensibleObject"] - assert models.User.get(id=user2.id).objectClass == [ + assert user2.get_ldap_attribute("objectClass") == [ + "inetOrgPerson", + "extensibleObject", + ] + assert models.User.get(id=user2.id).get_ldap_attribute("objectClass") == [ "inetOrgPerson", "extensibleObject", ] user1 = models.User.get(id=user1.id) - assert user1.objectClass == ["inetOrgPerson"] + assert user1.get_ldap_attribute("objectClass") == ["inetOrgPerson"] user1.save() - assert user1.objectClass == ["inetOrgPerson", "extensibleObject"] - assert models.User.get(id=user1.id).objectClass == [ + assert user1.get_ldap_attribute("objectClass") == [ + "inetOrgPerson", + "extensibleObject", + ] + assert models.User.get(id=user1.id).get_ldap_attribute("objectClass") == [ "inetOrgPerson", "extensibleObject", ] diff --git a/tests/backends/test_models.py b/tests/backends/test_models.py index 10bd6daa..88b5ce08 100644 --- a/tests/backends/test_models.py +++ b/tests/backends/test_models.py @@ -24,9 +24,6 @@ def test_required_methods(testclient): with pytest.raises(NotImplementedError): obj.delete() - with pytest.raises(NotImplementedError): - obj.update() - with pytest.raises(NotImplementedError): obj.reload() @@ -74,11 +71,11 @@ def test_model_lifecycle(testclient, backend): user.family_name = "new_family_name" - assert user.family_name == ["new_family_name"] + assert user.family_name == "new_family_name" user.reload() - assert user.family_name == ["family_name"] + assert user.family_name == "family_name" user.delete() @@ -96,29 +93,30 @@ def test_model_attribute_edition(testclient, backend): ) user.save() - assert user.user_name == ["user_name"] - assert user.family_name == ["family_name"] + assert user.user_name == "user_name" + assert user.family_name == "family_name" assert user.emails == ["email1@user.com", "email2@user.com"] user = models.User.get(id=user.id) - assert user.user_name == ["user_name"] - assert user.family_name == ["family_name"] + assert user.user_name == "user_name" + assert user.family_name == "family_name" assert user.emails == ["email1@user.com", "email2@user.com"] - user.family_name = ["new_family_name"] + user.family_name = "new_family_name" user.emails = ["email1@user.com"] user.save() - assert user.family_name == ["new_family_name"] + assert user.family_name == "new_family_name" assert user.emails == ["email1@user.com"] user = models.User.get(id=user.id) - assert user.family_name == ["new_family_name"] + assert user.family_name == "new_family_name" assert user.emails == ["email1@user.com"] - user.display_name = [""] - user.save() + user.display_name = "" + assert not user.display_name + user.save() assert not user.display_name user.delete() diff --git a/tests/core/test_groups.py b/tests/core/test_groups.py index a6f42ea6..81cb2ccb 100644 --- a/tests/core/test_groups.py +++ b/tests/core/test_groups.py @@ -152,7 +152,7 @@ def test_moderator_can_create_edit_and_delete_group( logged_moderator.reload() bar_group = models.Group.get(display_name="bar") assert bar_group.display_name == "bar" - assert bar_group.description == ["yolo"] + assert bar_group.description == "yolo" assert bar_group.members == [ logged_moderator ] # Group cannot be empty so creator is added in it @@ -162,7 +162,7 @@ def test_moderator_can_create_edit_and_delete_group( res = testclient.get("/groups/bar", status=200) form = res.forms["editgroupform"] form["display_name"] = "bar2" - form["description"] = ["yolo2"] + form["description"] = "yolo2" res = form.submit(name="action", value="edit") assert res.flashes == [("error", "Group edition failed.")] @@ -170,13 +170,13 @@ def test_moderator_can_create_edit_and_delete_group( bar_group = models.Group.get(display_name="bar") assert bar_group.display_name == "bar" - assert bar_group.description == ["yolo"] + assert bar_group.description == "yolo" assert models.Group.get(display_name="bar2") is None # Group description can be edited res = testclient.get("/groups/bar", status=200) form = res.forms["editgroupform"] - form["description"] = ["yolo2"] + form["description"] = "yolo2" res = form.submit(name="action", value="edit") assert res.flashes == [("success", "The group bar has been sucessfully edited.")] @@ -184,7 +184,7 @@ def test_moderator_can_create_edit_and_delete_group( bar_group = models.Group.get(display_name="bar") assert bar_group.display_name == "bar" - assert bar_group.description == ["yolo2"] + assert bar_group.description == "yolo2" # Group is deleted res = res.forms["editgroupform"].submit(name="action", value="confirm-delete") @@ -279,15 +279,15 @@ def test_user_list_search(testclient, logged_admin, foo_group, user, moderator): res = testclient.get("/groups/foo") res.mustcontain("3 items") - res.mustcontain(user.formatted_name[0]) - res.mustcontain(logged_admin.formatted_name[0]) - res.mustcontain(moderator.formatted_name[0]) + res.mustcontain(user.formatted_name) + res.mustcontain(logged_admin.formatted_name) + res.mustcontain(moderator.formatted_name) form = res.forms["search"] form["query"] = "ohn" res = form.submit() res.mustcontain("1 item") - res.mustcontain(user.formatted_name[0]) - res.mustcontain(no=logged_admin.formatted_name[0]) - res.mustcontain(no=moderator.formatted_name[0]) + res.mustcontain(user.formatted_name) + res.mustcontain(no=logged_admin.formatted_name) + res.mustcontain(no=moderator.formatted_name) diff --git a/tests/core/test_profile_creation.py b/tests/core/test_profile_creation.py index 47489af5..465b8edf 100644 --- a/tests/core/test_profile_creation.py +++ b/tests/core/test_profile_creation.py @@ -26,7 +26,7 @@ def test_user_creation_edition_and_deletion( res = res.follow(status=200) george = models.User.get_from_login("george") foo_group.reload() - assert "George" == george.given_name[0] + assert "George" == george.given_name assert george.groups == [foo_group] assert george.check_password("totoyolo")[0] @@ -46,7 +46,7 @@ def test_user_creation_edition_and_deletion( res = res.form.submit(name="action", value="edit-settings").follow() george = models.User.get_from_login("george") - assert "Georgio" == george.given_name[0] + assert "Georgio" == george.given_name assert george.check_password("totoyolo")[0] foo_group.reload() @@ -92,7 +92,7 @@ def test_user_creation_without_password(testclient, logged_moderator): assert ("success", "User account creation succeed.") in res.flashes res = res.follow(status=200) george = models.User.get_from_login("george") - assert george.user_name[0] == "george" + assert george.user_name == "george" assert not george.has_password() george.delete() @@ -145,7 +145,7 @@ def test_cn_setting_with_given_name_and_surname(testclient, logged_moderator): ) george = models.User.get_from_login("george") - assert george.formatted_name[0] == "George Abitbol" + assert george.formatted_name == "George Abitbol" george.delete() @@ -160,7 +160,7 @@ def test_cn_setting_with_surname_only(testclient, logged_moderator): ) george = models.User.get_from_login("george") - assert george.formatted_name[0] == "Abitbol" + assert george.formatted_name == "Abitbol" george.delete() diff --git a/tests/core/test_profile_edition.py b/tests/core/test_profile_edition.py index d6b06c56..cc6d6efc 100644 --- a/tests/core/test_profile_edition.py +++ b/tests/core/test_profile_edition.py @@ -57,16 +57,16 @@ def test_user_list_bad_pages(testclient, logged_admin): def test_user_list_search(testclient, logged_admin, user, moderator): res = testclient.get("/users") res.mustcontain("3 items") - res.mustcontain(moderator.formatted_name[0]) - res.mustcontain(user.formatted_name[0]) + res.mustcontain(moderator.formatted_name) + res.mustcontain(user.formatted_name) form = res.forms["search"] form["query"] = "Jack" res = form.submit() res.mustcontain("1 item") - res.mustcontain(moderator.formatted_name[0]) - res.mustcontain(no=user.formatted_name[0]) + res.mustcontain(moderator.formatted_name) + res.mustcontain(no=user.formatted_name) def test_user_list_search_only_allowed_fields( @@ -74,16 +74,16 @@ def test_user_list_search_only_allowed_fields( ): res = testclient.get("/users") res.mustcontain("3 items") - res.mustcontain(moderator.formatted_name[0]) - res.mustcontain(user.formatted_name[0]) + res.mustcontain(moderator.formatted_name) + res.mustcontain(user.formatted_name) form = res.forms["search"] form["query"] = "user" res = form.submit() res.mustcontain("1 item") - res.mustcontain(user.formatted_name[0]) - res.mustcontain(no=moderator.formatted_name[0]) + res.mustcontain(user.formatted_name) + res.mustcontain(no=moderator.formatted_name) testclient.app.config["ACL"]["DEFAULT"]["READ"].remove("user_name") g.user.reload() @@ -93,8 +93,8 @@ def test_user_list_search_only_allowed_fields( res = form.submit() res.mustcontain("0 items") - res.mustcontain(no=user.formatted_name[0]) - res.mustcontain(no=moderator.formatted_name[0]) + res.mustcontain(no=user.formatted_name) + res.mustcontain(no=moderator.formatted_name) def test_edition_permission( @@ -143,25 +143,25 @@ def test_edition( logged_user.reload() - assert logged_user.given_name == ["given_name"] - assert logged_user.family_name == ["family_name"] + assert logged_user.given_name == "given_name" + assert logged_user.family_name == "family_name" assert logged_user.display_name == "display_name" assert logged_user.emails == ["email@mydomain.tld"] assert logged_user.phone_numbers == ["555-666-777"] - assert logged_user.formatted_address == ["formatted_address"] - assert logged_user.street == ["street"] - assert logged_user.postal_code == ["postal_code"] - assert logged_user.locality == ["locality"] - assert logged_user.region == ["region"] + assert logged_user.formatted_address == "formatted_address" + assert logged_user.street == "street" + assert logged_user.postal_code == "postal_code" + assert logged_user.locality == "locality" + assert logged_user.region == "region" assert logged_user.preferred_language == "fr" assert logged_user.employee_number == "666" - assert logged_user.department == ["1337"] - assert logged_user.title == ["title"] - assert logged_user.organization == ["organization"] - assert logged_user.photo == [jpeg_photo] + assert logged_user.department == "1337" + assert logged_user.title == "title" + assert logged_user.organization == "organization" + assert logged_user.photo == jpeg_photo - logged_user.formatted_name = ["John (johnny) Doe"] - logged_user.family_name = ["Doe"] + logged_user.formatted_name = "John (johnny) Doe" + logged_user.family_name = "Doe" logged_user.emails = ["john@doe.com"] logged_user.given_name = None logged_user.photo = None @@ -331,7 +331,7 @@ def test_surname_is_mandatory(testclient, logged_user): logged_user.reload() - assert ["Doe"] == logged_user.family_name + assert "Doe" == logged_user.family_name def test_formcontrol(testclient, logged_user): diff --git a/tests/core/test_profile_photo.py b/tests/core/test_profile_photo.py index 010fcb58..b783a631 100644 --- a/tests/core/test_profile_photo.py +++ b/tests/core/test_profile_photo.py @@ -63,7 +63,7 @@ def test_photo_on_profile_edition( logged_user.reload() - assert [jpeg_photo] == logged_user.photo + assert logged_user.photo == jpeg_photo # No change res = testclient.get("/profile/user", status=200) @@ -75,7 +75,7 @@ def test_photo_on_profile_edition( logged_user.reload() - assert [jpeg_photo] == logged_user.photo + assert logged_user.photo == jpeg_photo # Photo deletion res = testclient.get("/profile/user", status=200) @@ -87,7 +87,7 @@ def test_photo_on_profile_edition( logged_user.reload() - assert logged_user.photo == [] + assert logged_user.photo is None # Photo deletion AND upload, this should never happen res = testclient.get("/profile/user", status=200) @@ -100,7 +100,7 @@ def test_photo_on_profile_edition( logged_user.reload() - assert [] == logged_user.photo + assert logged_user.photo is None def test_photo_on_profile_creation(testclient, jpeg_photo, logged_admin): @@ -119,7 +119,7 @@ def test_photo_on_profile_creation(testclient, jpeg_photo, logged_admin): ) user = models.User.get_from_login("foobar") - assert user.photo == [jpeg_photo] + assert user.photo == jpeg_photo user.delete() @@ -140,5 +140,5 @@ def test_photo_deleted_on_profile_creation(testclient, jpeg_photo, logged_admin) ) user = models.User.get_from_login("foobar") - assert user.photo == [] + assert user.photo is None user.delete() diff --git a/tests/core/test_profile_settings.py b/tests/core/test_profile_settings.py index 7732b76b..17fa3588 100644 --- a/tests/core/test_profile_settings.py +++ b/tests/core/test_profile_settings.py @@ -28,7 +28,7 @@ def test_edition( assert res.flashes == [("error", "Profile edition failed.")] logged_user.reload() - assert logged_user.user_name == ["user"] + assert logged_user.user_name == "user" foo_group.reload() bar_group.reload() @@ -38,7 +38,7 @@ def test_edition( assert logged_user.check_password("correct horse battery staple")[0] - logged_user.user_name = ["user"] + logged_user.user_name = "user" logged_user.save() @@ -72,10 +72,10 @@ def test_edition_without_groups( logged_user.reload() - assert logged_user.user_name == ["user"] + assert logged_user.user_name == "user" assert logged_user.check_password("correct horse battery staple")[0] - logged_user.user_name = ["user"] + logged_user.user_name = "user" logged_user.save() diff --git a/tests/oidc/commands/test_clean.py b/tests/oidc/commands/test_clean.py index a50e9e96..3067b155 100644 --- a/tests/oidc/commands/test_clean.py +++ b/tests/oidc/commands/test_clean.py @@ -47,7 +47,6 @@ def test_clean_command(testclient, backend, client, user): access_token="my-valid-token", client=client, subject=user, - type=None, refresh_token=gen_salt(48), scope="openid profile", issue_date=( @@ -61,7 +60,6 @@ def test_clean_command(testclient, backend, client, user): access_token="my-expired-token", client=client, subject=user, - type=None, refresh_token=gen_salt(48), scope="openid profile", issue_date=( @@ -81,5 +79,5 @@ def test_clean_command(testclient, backend, client, user): res = runner.invoke(cli, ["clean"]) assert res.exit_code == 0, res.stdout - assert models.AuthorizationCode.query() == [valid_code] - assert models.Token.query() == [valid_token] + assert models.AuthorizationCode.get() == valid_code + assert models.Token.get() == valid_token diff --git a/tests/oidc/test_authorization_code_flow.py b/tests/oidc/test_authorization_code_flow.py index dafef4bc..787fdcdf 100644 --- a/tests/oidc/test_authorization_code_flow.py +++ b/tests/oidc/test_authorization_code_flow.py @@ -80,14 +80,14 @@ def test_authorization_code_flow( "phone", } claims = jwt.decode(access_token, keypair[1]) - assert claims["sub"] == logged_user.user_name[0] - assert claims["name"] == logged_user.formatted_name[0] + assert claims["sub"] == logged_user.user_name + assert claims["name"] == logged_user.formatted_name assert claims["aud"] == [client.client_id, other_client.client_id] id_token = res.json["id_token"] claims = jwt.decode(id_token, keypair[1]) - assert claims["sub"] == logged_user.user_name[0] - assert claims["name"] == logged_user.formatted_name[0] + assert claims["sub"] == logged_user.user_name + assert claims["name"] == logged_user.formatted_name assert claims["aud"] == [client.client_id, other_client.client_id] res = testclient.get( @@ -208,8 +208,8 @@ def test_authorization_code_flow_preconsented( id_token = res.json["id_token"] claims = jwt.decode(id_token, keypair[1]) - assert logged_user.user_name[0] == claims["sub"] - assert logged_user.formatted_name[0] == claims["name"] + assert logged_user.user_name == claims["sub"] + assert logged_user.formatted_name == claims["name"] assert [client.client_id, other_client.client_id] == claims["aud"] res = testclient.get( @@ -240,7 +240,7 @@ def test_logout_login(testclient, logged_user, client): res = res.follow() res = res.follow() - res.form["login"] = logged_user.user_name[0] + res.form["login"] = logged_user.user_name res = res.form.submit() res = res.follow() @@ -767,8 +767,8 @@ def test_authorization_code_request_scope_too_large( id_token = res.json["id_token"] claims = jwt.decode(id_token, keypair[1]) - assert logged_user.user_name[0] == claims["sub"] - assert logged_user.formatted_name[0] == claims["name"] + assert logged_user.user_name == claims["sub"] + assert logged_user.formatted_name == claims["name"] res = testclient.get( "/oauth/userinfo", diff --git a/tests/oidc/test_consent.py b/tests/oidc/test_consent.py index 993d355e..3b17ab88 100644 --- a/tests/oidc/test_consent.py +++ b/tests/oidc/test_consent.py @@ -18,7 +18,7 @@ def test_revokation(testclient, client, consent, logged_user, token): assert not consent.revoked assert not token.revoked - res = testclient.get(f"/consent/revoke/{consent.consent_id[0]}", status=302) + res = testclient.get(f"/consent/revoke/{consent.consent_id}", status=302) assert ("success", "The access has been revoked") in res.flashes res = res.follow(status=200) res.mustcontain(no="Revoke access") @@ -36,7 +36,7 @@ def test_revokation_already_revoked(testclient, client, consent, logged_user): consent.reload() assert consent.revoked - res = testclient.get(f"/consent/revoke/{consent.consent_id[0]}", status=302) + res = testclient.get(f"/consent/revoke/{consent.consent_id}", status=302) assert ("error", "The access is already revoked") in res.flashes res = res.follow(status=200) @@ -52,7 +52,7 @@ def test_restoration(testclient, client, consent, logged_user, token): token.reload() assert token.revoked - res = testclient.get(f"/consent/restore/{consent.consent_id[0]}", status=302) + res = testclient.get(f"/consent/restore/{consent.consent_id}", status=302) assert ("success", "The access has been restored") in res.flashes res = res.follow(status=200) @@ -65,7 +65,7 @@ def test_restoration(testclient, client, consent, logged_user, token): def test_restoration_already_restored(testclient, client, consent, logged_user, token): assert not consent.revoked - res = testclient.get(f"/consent/restore/{consent.consent_id[0]}", status=302) + res = testclient.get(f"/consent/restore/{consent.consent_id}", status=302) assert ("error", "The access is not revoked") in res.flashes res = res.follow(status=200) @@ -77,7 +77,7 @@ def test_invalid_consent_revokation(testclient, client, logged_user): def test_someone_else_consent_revokation(testclient, client, consent, logged_moderator): - res = testclient.get(f"/consent/revoke/{consent.consent_id[0]}", status=302) + res = testclient.get(f"/consent/revoke/{consent.consent_id}", status=302) assert ("success", "The access has been revoked") not in res.flashes assert ("error", "Could not revoke this access") in res.flashes @@ -91,7 +91,7 @@ def test_invalid_consent_restoration(testclient, client, logged_user): def test_someone_else_consent_restoration( testclient, client, consent, logged_moderator ): - res = testclient.get(f"/consent/restore/{consent.consent_id[0]}", status=302) + res = testclient.get(f"/consent/restore/{consent.consent_id}", status=302) assert ("success", "The access has been restore") not in res.flashes assert ("error", "Could not restore this access") in res.flashes @@ -171,7 +171,7 @@ def test_revoke_preconsented_client(testclient, client, logged_user, token): token.reload() assert token.revoked - res = testclient.get(f"/consent/restore/{consent.consent_id[0]}", status=302) + res = testclient.get(f"/consent/restore/{consent.consent_id}", status=302) assert ("success", "The access has been restored") in res.flashes consent.reload() @@ -180,7 +180,7 @@ def test_revoke_preconsented_client(testclient, client, logged_user, token): token.reload() assert token.revoked - res = testclient.get(f"/consent/revoke/{consent.consent_id[0]}", status=302) + res = testclient.get(f"/consent/revoke/{consent.consent_id}", status=302) assert ("success", "The access has been revoked") in res.flashes consent.reload() assert consent.revoked diff --git a/tests/oidc/test_hybrid_flow.py b/tests/oidc/test_hybrid_flow.py index dfc971c0..e8bffc1a 100644 --- a/tests/oidc/test_hybrid_flow.py +++ b/tests/oidc/test_hybrid_flow.py @@ -17,7 +17,7 @@ def test_oauth_hybrid(testclient, backend, user, client): ).follow() assert "text/html" == res.content_type, res.json - res.form["login"] = user.user_name[0] + res.form["login"] = user.user_name res = res.form.submit(status=302).follow() res.form["password"] = "correct horse battery staple" @@ -73,8 +73,8 @@ def test_oidc_hybrid(testclient, backend, logged_user, client, keypair, other_cl id_token = params["id_token"][0] claims = jwt.decode(id_token, keypair[1]) - assert logged_user.user_name[0] == claims["sub"] - assert logged_user.formatted_name[0] == claims["name"] + assert logged_user.user_name == claims["sub"] + assert logged_user.formatted_name == claims["name"] assert [client.client_id, other_client.client_id] == claims["aud"] res = testclient.get( diff --git a/tests/oidc/test_implicit_flow.py b/tests/oidc/test_implicit_flow.py index 321a4027..4cc3d945 100644 --- a/tests/oidc/test_implicit_flow.py +++ b/tests/oidc/test_implicit_flow.py @@ -86,8 +86,8 @@ def test_oidc_implicit(testclient, keypair, user, client, other_client): id_token = params["id_token"][0] claims = jwt.decode(id_token, keypair[1]) - assert user.user_name[0] == claims["sub"] - assert user.formatted_name[0] == claims["name"] + assert user.user_name == claims["sub"] + assert user.formatted_name == claims["name"] assert [client.client_id, other_client.client_id] == claims["aud"] res = testclient.get( @@ -141,8 +141,8 @@ def test_oidc_implicit_with_group( id_token = params["id_token"][0] claims = jwt.decode(id_token, keypair[1]) - assert user.user_name[0] == claims["sub"] - assert user.formatted_name[0] == claims["name"] + assert user.user_name == claims["sub"] + assert user.formatted_name == claims["name"] assert [client.client_id, other_client.client_id] == claims["aud"] assert ["foo"] == claims["groups"] diff --git a/tests/oidc/test_token_introspection.py b/tests/oidc/test_token_introspection.py index deca1fba..bd92c5c4 100644 --- a/tests/oidc/test_token_introspection.py +++ b/tests/oidc/test_token_introspection.py @@ -17,9 +17,9 @@ def test_access_token_introspection(testclient, user, client, token): "active": True, "client_id": client.client_id, "token_type": token.type, - "username": user.formatted_name[0], + "username": user.formatted_name, "scope": token.get_scope(), - "sub": user.user_name[0], + "sub": user.user_name, "aud": [client.client_id], "iss": "https://auth.mydomain.tld", "exp": token.get_expires_at(), @@ -38,9 +38,9 @@ def test_refresh_token_introspection(testclient, user, client, token): "active": True, "client_id": client.client_id, "token_type": token.type, - "username": user.formatted_name[0], + "username": user.formatted_name, "scope": token.get_scope(), - "sub": user.user_name[0], + "sub": user.user_name, "aud": [client.client_id], "iss": "https://auth.mydomain.tld", "exp": token.get_expires_at(), @@ -108,9 +108,9 @@ def test_full_flow(testclient, logged_user, client, user, other_client): "active": True, "client_id": client.client_id, "token_type": token.type, - "username": user.formatted_name[0], + "username": user.formatted_name, "scope": token.get_scope(), - "sub": user.user_name[0], + "sub": user.user_name, "iss": "https://auth.mydomain.tld", "exp": token.get_expires_at(), "iat": token.get_issued_at(), diff --git a/tests/oidc/test_userinfo.py b/tests/oidc/test_userinfo.py index 5a2b8019..640da066 100644 --- a/tests/oidc/test_userinfo.py +++ b/tests/oidc/test_userinfo.py @@ -284,7 +284,7 @@ def test_generate_user_standard_claims_with_default_config(testclient, backend, def test_custom_config_format_claim_is_well_formated(testclient, backend, user): jwt_mapping_config = DEFAULT_JWT_MAPPING.copy() - jwt_mapping_config["EMAIL"] = "{{ user.user_name[0] }}@mydomain.tld" + jwt_mapping_config["EMAIL"] = "{{ user.user_name }}@mydomain.tld" data = generate_user_claims(user, STANDARD_CLAIMS, jwt_mapping_config)