Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ def from_domain_model(
class MyProfilePatch(InputSchemaWithoutCamelCase):
first_name: FirstNameStr | None = None
last_name: LastNameStr | None = None
user_name: Annotated[IDStr | None, Field(alias="userName")] = None
user_name: Annotated[IDStr | None, Field(alias="userName", min_length=4)] = None

privacy: MyProfilePrivacyPatch | None = None

Expand Down Expand Up @@ -169,7 +169,7 @@ def _validate_user_name(cls, value: str):

# Ensure it doesn't end with a special character
if {value[0], value[-1]}.intersection({"_", "-", "."}):
msg = f"Username '{value}' cannot end or start with a special character."
msg = f"Username '{value}' cannot end with a special character."
raise ValueError(msg)

# Check reserved words (example list; extend as needed)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# pylint: disable=redefined-outer-name
# pylint: disable=too-many-arguments
# pylint: disable=unused-argument
# pylint: disable=unused-variable

from copy import deepcopy

import pytest
from common_library.users_enums import UserRole
from models_library.api_schemas_webserver.users import (
MyProfileGet,
MyProfilePatch,
)
from pydantic import ValidationError


@pytest.mark.parametrize("user_role", [u.name for u in UserRole])
def test_profile_get_role(user_role: str):
for example in MyProfileGet.model_json_schema()["examples"]:
data = deepcopy(example)
data["role"] = user_role
m1 = MyProfileGet(**data)

data["role"] = UserRole(user_role)
m2 = MyProfileGet(**data)
assert m1 == m2


def test_my_profile_patch_username_min_len():
# minimum length username is 4
with pytest.raises(ValidationError) as err_info:
MyProfilePatch.model_validate({"userName": "abc"})

assert err_info.value.error_count() == 1
assert err_info.value.errors()[0]["type"] == "too_short"

MyProfilePatch.model_validate({"userName": "abcd"}) # OK


def test_my_profile_patch_username_valid_characters():
# Ensure valid characters (alphanumeric + . _ -)
with pytest.raises(ValidationError, match="start with a letter") as err_info:
MyProfilePatch.model_validate({"userName": "1234"})

assert err_info.value.error_count() == 1
assert err_info.value.errors()[0]["type"] == "value_error"

MyProfilePatch.model_validate({"userName": "u1234"}) # OK


def test_my_profile_patch_username_special_characters():
# Ensure no consecutive special characters
with pytest.raises(
ValidationError, match="consecutive special characters"
) as err_info:
MyProfilePatch.model_validate({"userName": "u1__234"})

assert err_info.value.error_count() == 1
assert err_info.value.errors()[0]["type"] == "value_error"

MyProfilePatch.model_validate({"userName": "u1_234"}) # OK

# Ensure it doesn't end with a special character
with pytest.raises(ValidationError, match="end with") as err_info:
MyProfilePatch.model_validate({"userName": "u1234_"})

assert err_info.value.error_count() == 1
assert err_info.value.errors()[0]["type"] == "value_error"

MyProfilePatch.model_validate({"userName": "u1_234"}) # OK


def test_my_profile_patch_username_reserved_words():
# Check reserved words (example list; extend as needed)
with pytest.raises(ValidationError, match="cannot be used") as err_info:
MyProfilePatch.model_validate({"userName": "admin"})

assert err_info.value.error_count() == 1
assert err_info.value.errors()[0]["type"] == "value_error"

MyProfilePatch.model_validate({"userName": "midas"}) # OK
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import secrets
import string
from datetime import datetime
from typing import Any, Final

import sqlalchemy as sa
from aiopg.sa.connection import SAConnection
Expand All @@ -25,19 +26,29 @@ class UserNotFoundInRepoError(BaseUserRepoError):
pass


# NOTE: see MyProfilePatch.user_name
_MIN_USERNAME_LEN: Final[int] = 4


def _generate_random_chars(length: int = _MIN_USERNAME_LEN) -> str:
"""returns `length` random digit character"""
return "".join(secrets.choice(string.digits) for _ in range(length))


def _generate_username_from_email(email: str) -> str:
username = email.split("@")[0]

# Remove any non-alphanumeric characters and convert to lowercase
return re.sub(r"[^a-zA-Z0-9]", "", username).lower()
username = re.sub(r"[^a-zA-Z0-9]", "", username).lower()

# Ensure the username is at least 4 characters long
if len(username) < _MIN_USERNAME_LEN:
username += _generate_random_chars(length=_MIN_USERNAME_LEN - len(username))

def _generate_random_chars(length=5) -> str:
"""returns `length` random digit character"""
return "".join(secrets.choice(string.digits) for _ in range(length - 1))
return username


def generate_alternative_username(username) -> str:
def generate_alternative_username(username: str) -> str:
return f"{username}_{_generate_random_chars()}"


Expand All @@ -50,7 +61,7 @@ async def new_user(
status: UserStatus,
expires_at: datetime | None,
) -> RowProxy:
data = {
data: dict[str, Any] = {
"name": _generate_username_from_email(email),
"email": email,
"password_hash": password_hash,
Expand Down
6 changes: 3 additions & 3 deletions packages/postgres-database/tests/test_users.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@
from simcore_postgres_database.models.users import UserRole, UserStatus, users
from simcore_postgres_database.utils_users import (
UsersRepo,
_generate_random_chars,
_generate_username_from_email,
generate_alternative_username,
)
from sqlalchemy.sql import func

Expand Down Expand Up @@ -92,7 +92,7 @@ async def test_unique_username(
faker,
status=UserStatus.ACTIVE,
name="pcrespov",
email="some-fanky-name@email.com",
email="p@email.com",
first_name="Pedro",
last_name="Crespo Valero",
)
Expand All @@ -116,7 +116,7 @@ async def test_unique_username(
await connection.scalar(users.insert().values(data).returning(users.c.id))

# and another one
data["name"] += _generate_random_chars()
data["name"] = generate_alternative_username(data["name"])
data["email"] = faker.email()
await connection.scalar(users.insert().values(data).returning(users.c.id))

Expand Down
2 changes: 1 addition & 1 deletion services/web/server/VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.61.1
0.61.2
2 changes: 1 addition & 1 deletion services/web/server/setup.cfg
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[bumpversion]
current_version = 0.61.1
current_version = 0.61.2
commit = True
message = services/webserver api version: {current_version} → {new_version}
tag = False
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
openapi: 3.1.0
info:
title: simcore-service-webserver
description: Main service with an interface (http-API & websockets) to the web front-end
version: 0.61.1
version: 0.61.2
servers:
- url: ''
description: webserver
Expand Down Expand Up @@ -11738,7 +11738,7 @@
anyOf:
- type: string
maxLength: 100
minLength: 1
minLength: 4
- type: 'null'
title: Username
privacy:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -557,11 +557,12 @@ async def update_user_profile(
)

except IntegrityError as err:
user_name = updated_values.get("name")

raise UserNameDuplicateError(
user_name=user_name,
alternative_user_name=generate_alternative_username(user_name),
user_id=user_id,
updated_values=updated_values,
) from err
if user_name := updated_values.get("name"):
raise UserNameDuplicateError(
user_name=user_name,
alternative_user_name=generate_alternative_username(user_name),
user_id=user_id,
updated_values=updated_values,
) from err

raise # not due to name duplication
62 changes: 12 additions & 50 deletions services/web/server/tests/unit/isolated/test_users_models.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
# pylint: disable=protected-access
# pylint: disable=redefined-outer-name
# pylint: disable=too-many-arguments
# pylint: disable=unused-argument
# pylint: disable=unused-variable
# pylint: disable=too-many-arguments

import itertools
from copy import deepcopy
from datetime import UTC, datetime
from typing import Any

Expand All @@ -16,46 +15,12 @@
MyProfilePrivacyGet,
)
from models_library.generics import Envelope
from models_library.users import UserThirdPartyToken
from models_library.utils.fastapi_encoders import jsonable_encoder
from pydantic import BaseModel
from pytest_simcore.pydantic_models import (
assert_validation_model,
iter_model_examples_in_class,
)
from servicelib.rest_constants import RESPONSE_MODEL_POLICY
from simcore_postgres_database.models.users import UserRole
from simcore_postgres_database import utils_users
from simcore_service_webserver.users._common.models import ToUserUpdateDB


@pytest.mark.parametrize(
"model_cls, example_name, example_data",
itertools.chain(
iter_model_examples_in_class(MyProfileGet),
iter_model_examples_in_class(UserThirdPartyToken),
),
)
def test_user_models_examples(
model_cls: type[BaseModel], example_name: str, example_data: Any
):
model_instance = assert_validation_model(
model_cls, example_name=example_name, example_data=example_data
)

model_enveloped = Envelope[model_cls].from_data(
model_instance.model_dump(by_alias=True)
)
model_array_enveloped = Envelope[list[model_cls]].from_data(
[
model_instance.model_dump(by_alias=True),
model_instance.model_dump(by_alias=True),
]
)

assert model_enveloped.error is None
assert model_array_enveloped.error is None


@pytest.fixture
def fake_profile_get(faker: Faker) -> MyProfileGet:
fake_profile: dict[str, Any] = faker.simple_profile()
Expand Down Expand Up @@ -104,18 +69,6 @@ def test_auto_compute_gravatar__deprecated(fake_profile_get: MyProfileGet):
assert data["preferences"] == profile.preferences


@pytest.mark.parametrize("user_role", [u.name for u in UserRole])
def test_profile_get_role(user_role: str):
for example in MyProfileGet.model_json_schema()["examples"]:
data = deepcopy(example)
data["role"] = user_role
m1 = MyProfileGet(**data)

data["role"] = UserRole(user_role)
m2 = MyProfileGet(**data)
assert m1 == m2


def test_parsing_output_of_get_user_profile():
result_from_db_query_and_composition = {
"id": 1,
Expand Down Expand Up @@ -185,3 +138,12 @@ def test_mapping_update_models_from_rest_to_db():
"name": "foo1234",
"privacy_hide_fullname": False,
}


def test_utils_user_generates_valid_myprofile_patch():
username = utils_users._generate_username_from_email("[email protected]") # noqa: SLF001

MyProfilePatch.model_validate({"userName": username})
MyProfilePatch.model_validate(
{"userName": utils_users.generate_alternative_username(username)}
)
Loading