Skip to content

Commit 9ca3797

Browse files
pcrespovmrnicegyu11
authored andcommitted
🎨 web-api: patch userName at least 4 chars ⚠️ (ITISFoundation#7389)
1 parent d980c55 commit 9ca3797

File tree

9 files changed

+128
-73
lines changed

9 files changed

+128
-73
lines changed

packages/models-library/src/models_library/api_schemas_webserver/users.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ def from_domain_model(
141141
class MyProfilePatch(InputSchemaWithoutCamelCase):
142142
first_name: FirstNameStr | None = None
143143
last_name: LastNameStr | None = None
144-
user_name: Annotated[IDStr | None, Field(alias="userName")] = None
144+
user_name: Annotated[IDStr | None, Field(alias="userName", min_length=4)] = None
145145

146146
privacy: MyProfilePrivacyPatch | None = None
147147

@@ -169,7 +169,7 @@ def _validate_user_name(cls, value: str):
169169

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

175175
# Check reserved words (example list; extend as needed)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# pylint: disable=redefined-outer-name
2+
# pylint: disable=too-many-arguments
3+
# pylint: disable=unused-argument
4+
# pylint: disable=unused-variable
5+
6+
from copy import deepcopy
7+
8+
import pytest
9+
from common_library.users_enums import UserRole
10+
from models_library.api_schemas_webserver.users import (
11+
MyProfileGet,
12+
MyProfilePatch,
13+
)
14+
from pydantic import ValidationError
15+
16+
17+
@pytest.mark.parametrize("user_role", [u.name for u in UserRole])
18+
def test_profile_get_role(user_role: str):
19+
for example in MyProfileGet.model_json_schema()["examples"]:
20+
data = deepcopy(example)
21+
data["role"] = user_role
22+
m1 = MyProfileGet(**data)
23+
24+
data["role"] = UserRole(user_role)
25+
m2 = MyProfileGet(**data)
26+
assert m1 == m2
27+
28+
29+
def test_my_profile_patch_username_min_len():
30+
# minimum length username is 4
31+
with pytest.raises(ValidationError) as err_info:
32+
MyProfilePatch.model_validate({"userName": "abc"})
33+
34+
assert err_info.value.error_count() == 1
35+
assert err_info.value.errors()[0]["type"] == "too_short"
36+
37+
MyProfilePatch.model_validate({"userName": "abcd"}) # OK
38+
39+
40+
def test_my_profile_patch_username_valid_characters():
41+
# Ensure valid characters (alphanumeric + . _ -)
42+
with pytest.raises(ValidationError, match="start with a letter") as err_info:
43+
MyProfilePatch.model_validate({"userName": "1234"})
44+
45+
assert err_info.value.error_count() == 1
46+
assert err_info.value.errors()[0]["type"] == "value_error"
47+
48+
MyProfilePatch.model_validate({"userName": "u1234"}) # OK
49+
50+
51+
def test_my_profile_patch_username_special_characters():
52+
# Ensure no consecutive special characters
53+
with pytest.raises(
54+
ValidationError, match="consecutive special characters"
55+
) as err_info:
56+
MyProfilePatch.model_validate({"userName": "u1__234"})
57+
58+
assert err_info.value.error_count() == 1
59+
assert err_info.value.errors()[0]["type"] == "value_error"
60+
61+
MyProfilePatch.model_validate({"userName": "u1_234"}) # OK
62+
63+
# Ensure it doesn't end with a special character
64+
with pytest.raises(ValidationError, match="end with") as err_info:
65+
MyProfilePatch.model_validate({"userName": "u1234_"})
66+
67+
assert err_info.value.error_count() == 1
68+
assert err_info.value.errors()[0]["type"] == "value_error"
69+
70+
MyProfilePatch.model_validate({"userName": "u1_234"}) # OK
71+
72+
73+
def test_my_profile_patch_username_reserved_words():
74+
# Check reserved words (example list; extend as needed)
75+
with pytest.raises(ValidationError, match="cannot be used") as err_info:
76+
MyProfilePatch.model_validate({"userName": "admin"})
77+
78+
assert err_info.value.error_count() == 1
79+
assert err_info.value.errors()[0]["type"] == "value_error"
80+
81+
MyProfilePatch.model_validate({"userName": "midas"}) # OK

packages/postgres-database/src/simcore_postgres_database/utils_users.py

+17-6
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import secrets
77
import string
88
from datetime import datetime
9+
from typing import Any, Final
910

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

2728

29+
# NOTE: see MyProfilePatch.user_name
30+
_MIN_USERNAME_LEN: Final[int] = 4
31+
32+
33+
def _generate_random_chars(length: int = _MIN_USERNAME_LEN) -> str:
34+
"""returns `length` random digit character"""
35+
return "".join(secrets.choice(string.digits) for _ in range(length))
36+
37+
2838
def _generate_username_from_email(email: str) -> str:
2939
username = email.split("@")[0]
3040

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

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

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

3950

40-
def generate_alternative_username(username) -> str:
51+
def generate_alternative_username(username: str) -> str:
4152
return f"{username}_{_generate_random_chars()}"
4253

4354

@@ -50,7 +61,7 @@ async def new_user(
5061
status: UserStatus,
5162
expires_at: datetime | None,
5263
) -> RowProxy:
53-
data = {
64+
data: dict[str, Any] = {
5465
"name": _generate_username_from_email(email),
5566
"email": email,
5667
"password_hash": password_hash,

packages/postgres-database/tests/test_users.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@
1818
from simcore_postgres_database.models.users import UserRole, UserStatus, users
1919
from simcore_postgres_database.utils_users import (
2020
UsersRepo,
21-
_generate_random_chars,
2221
_generate_username_from_email,
22+
generate_alternative_username,
2323
)
2424
from sqlalchemy.sql import func
2525

@@ -92,7 +92,7 @@ async def test_unique_username(
9292
faker,
9393
status=UserStatus.ACTIVE,
9494
name="pcrespov",
95-
email="some-fanky-name@email.com",
95+
email="p@email.com",
9696
first_name="Pedro",
9797
last_name="Crespo Valero",
9898
)
@@ -116,7 +116,7 @@ async def test_unique_username(
116116
await connection.scalar(users.insert().values(data).returning(users.c.id))
117117

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

services/web/server/VERSION

+1-1
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.61.1
1+
0.61.2

services/web/server/setup.cfg

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[bumpversion]
2-
current_version = 0.61.1
2+
current_version = 0.61.2
33
commit = True
44
message = services/webserver api version: {current_version} → {new_version}
55
tag = False

services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml

+2-2
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ openapi: 3.1.0
22
info:
33
title: simcore-service-webserver
44
description: Main service with an interface (http-API & websockets) to the web front-end
5-
version: 0.61.1
5+
version: 0.61.2
66
servers:
77
- url: ''
88
description: webserver
@@ -11738,7 +11738,7 @@ components:
1173811738
anyOf:
1173911739
- type: string
1174011740
maxLength: 100
11741-
minLength: 1
11741+
minLength: 4
1174211742
- type: 'null'
1174311743
title: Username
1174411744
privacy:

services/web/server/src/simcore_service_webserver/users/_users_repository.py

+9-8
Original file line numberDiff line numberDiff line change
@@ -557,11 +557,12 @@ async def update_user_profile(
557557
)
558558

559559
except IntegrityError as err:
560-
user_name = updated_values.get("name")
561-
562-
raise UserNameDuplicateError(
563-
user_name=user_name,
564-
alternative_user_name=generate_alternative_username(user_name),
565-
user_id=user_id,
566-
updated_values=updated_values,
567-
) from err
560+
if user_name := updated_values.get("name"):
561+
raise UserNameDuplicateError(
562+
user_name=user_name,
563+
alternative_user_name=generate_alternative_username(user_name),
564+
user_id=user_id,
565+
updated_values=updated_values,
566+
) from err
567+
568+
raise # not due to name duplication

services/web/server/tests/unit/isolated/test_users_models.py

+12-50
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
1+
# pylint: disable=protected-access
12
# pylint: disable=redefined-outer-name
3+
# pylint: disable=too-many-arguments
24
# pylint: disable=unused-argument
35
# pylint: disable=unused-variable
4-
# pylint: disable=too-many-arguments
56

6-
import itertools
7-
from copy import deepcopy
87
from datetime import UTC, datetime
98
from typing import Any
109

@@ -16,46 +15,12 @@
1615
MyProfilePrivacyGet,
1716
)
1817
from models_library.generics import Envelope
19-
from models_library.users import UserThirdPartyToken
2018
from models_library.utils.fastapi_encoders import jsonable_encoder
21-
from pydantic import BaseModel
22-
from pytest_simcore.pydantic_models import (
23-
assert_validation_model,
24-
iter_model_examples_in_class,
25-
)
2619
from servicelib.rest_constants import RESPONSE_MODEL_POLICY
27-
from simcore_postgres_database.models.users import UserRole
20+
from simcore_postgres_database import utils_users
2821
from simcore_service_webserver.users._common.models import ToUserUpdateDB
2922

3023

31-
@pytest.mark.parametrize(
32-
"model_cls, example_name, example_data",
33-
itertools.chain(
34-
iter_model_examples_in_class(MyProfileGet),
35-
iter_model_examples_in_class(UserThirdPartyToken),
36-
),
37-
)
38-
def test_user_models_examples(
39-
model_cls: type[BaseModel], example_name: str, example_data: Any
40-
):
41-
model_instance = assert_validation_model(
42-
model_cls, example_name=example_name, example_data=example_data
43-
)
44-
45-
model_enveloped = Envelope[model_cls].from_data(
46-
model_instance.model_dump(by_alias=True)
47-
)
48-
model_array_enveloped = Envelope[list[model_cls]].from_data(
49-
[
50-
model_instance.model_dump(by_alias=True),
51-
model_instance.model_dump(by_alias=True),
52-
]
53-
)
54-
55-
assert model_enveloped.error is None
56-
assert model_array_enveloped.error is None
57-
58-
5924
@pytest.fixture
6025
def fake_profile_get(faker: Faker) -> MyProfileGet:
6126
fake_profile: dict[str, Any] = faker.simple_profile()
@@ -104,18 +69,6 @@ def test_auto_compute_gravatar__deprecated(fake_profile_get: MyProfileGet):
10469
assert data["preferences"] == profile.preferences
10570

10671

107-
@pytest.mark.parametrize("user_role", [u.name for u in UserRole])
108-
def test_profile_get_role(user_role: str):
109-
for example in MyProfileGet.model_json_schema()["examples"]:
110-
data = deepcopy(example)
111-
data["role"] = user_role
112-
m1 = MyProfileGet(**data)
113-
114-
data["role"] = UserRole(user_role)
115-
m2 = MyProfileGet(**data)
116-
assert m1 == m2
117-
118-
11972
def test_parsing_output_of_get_user_profile():
12073
result_from_db_query_and_composition = {
12174
"id": 1,
@@ -185,3 +138,12 @@ def test_mapping_update_models_from_rest_to_db():
185138
"name": "foo1234",
186139
"privacy_hide_fullname": False,
187140
}
141+
142+
143+
def test_utils_user_generates_valid_myprofile_patch():
144+
username = utils_users._generate_username_from_email("[email protected]") # noqa: SLF001
145+
146+
MyProfilePatch.model_validate({"userName": username})
147+
MyProfilePatch.model_validate(
148+
{"userName": utils_users.generate_alternative_username(username)}
149+
)

0 commit comments

Comments
 (0)