Skip to content

Commit 76dffd5

Browse files
committed
update username
1 parent 4a1b23d commit 76dffd5

File tree

4 files changed

+87
-2
lines changed

4 files changed

+87
-2
lines changed

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ async def get_my_profile(request: web.Request) -> web.Response:
8282
async def update_my_profile(request: web.Request) -> web.Response:
8383
req_ctx = UsersRequestContext.model_validate(request)
8484
profile_update = await parse_request_body_as(ProfileUpdate, request)
85+
8586
await api.update_user_profile(
8687
request.app, user_id=req_ctx.user_id, update=profile_update
8788
)

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ async def update_user_profile(
164164
include={
165165
"first_name": True,
166166
"last_name": True,
167+
"user_name": True,
167168
"privacy": {
168169
"hide_email",
169170
"hide_fullname",

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

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import re
12
from datetime import date
23
from typing import Annotated, Literal
34
from uuid import UUID
@@ -103,7 +104,7 @@ def _to_upper_string(cls, v):
103104
class ProfileUpdate(BaseModel):
104105
first_name: FirstNameStr | None = None
105106
last_name: LastNameStr | None = None
106-
107+
user_name: IDStr | None = None
107108
privacy: ProfilePrivacyUpdate | None = None
108109

109110
model_config = ConfigDict(
@@ -115,6 +116,46 @@ class ProfileUpdate(BaseModel):
115116
}
116117
)
117118

119+
@field_validator("user_name")
120+
@classmethod
121+
def _validate_user_name(cls, value):
122+
# Ensure valid characters (alphanumeric + . _ -)
123+
if not re.match(r"^[a-zA-Z][a-zA-Z0-9_]*$", value):
124+
msg = "Username must start with a letter and can only contain letters, numbers and '_'."
125+
raise ValueError(msg)
126+
127+
# Ensure no consecutive special characters
128+
if re.search(r"[_]{2,}", value):
129+
msg = "Username cannot contain consecutive special characters like '__'."
130+
raise ValueError(msg)
131+
132+
# Ensure it doesn't end with a special character
133+
if value[-1] in {".", "_", "-"}:
134+
msg = "Username cannot end with a special character."
135+
raise ValueError(msg)
136+
137+
# Check reserved words (example list; extend as needed)
138+
reserved_words = {
139+
"admin",
140+
"root",
141+
"system",
142+
"null",
143+
"undefined",
144+
"support",
145+
"moderator",
146+
}
147+
if value.lower() in reserved_words:
148+
msg = f"Username '{value}' is reserved and cannot be used."
149+
raise ValueError(msg)
150+
151+
# Ensure no offensive content
152+
offensive_terms = {"badword1", "badword2"}
153+
if any(term in value.lower() for term in offensive_terms):
154+
msg = "Username contains prohibited or offensive content"
155+
raise ValueError(msg)
156+
157+
return value
158+
118159

119160
#
120161
# Permissions

services/web/server/tests/unit/with_dbs/03/test_users.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,8 @@ async def test_profile_workflow(
217217
resp = await client.patch(
218218
f"{url}",
219219
json={
220-
"first_name": "Odei",
220+
"firstName": "Odei",
221+
"userName": "odei123",
221222
"privacy": {"hide_fullname": False},
222223
},
223224
)
@@ -232,11 +233,52 @@ async def test_profile_workflow(
232233
assert updated_profile.last_name == my_profile.last_name
233234
assert updated_profile.login == my_profile.login
234235

236+
assert updated_profile.user_name != my_profile.user_name
237+
assert updated_profile.user_name == "odei123"
238+
235239
assert updated_profile.privacy != my_profile.privacy
236240
assert updated_profile.privacy.hide_email == my_profile.privacy.hide_email
237241
assert updated_profile.privacy.hide_fullname != my_profile.privacy.hide_fullname
238242

239243

244+
@pytest.mark.parametrize(
245+
"user_role",
246+
[UserRole.USER],
247+
)
248+
async def test_update_wrong_user_name(
249+
logged_user: UserInfoDict,
250+
client: TestClient,
251+
user_role: UserRole,
252+
):
253+
assert client.app
254+
255+
# update with INVALID username
256+
url = client.app.router["update_my_profile"].url_for()
257+
258+
for invalid_username in ("_foo", "superadmin", "foo..-123"):
259+
resp = await client.patch(
260+
f"{url}",
261+
json={
262+
"userName": invalid_username,
263+
},
264+
)
265+
await assert_status(resp, status.HTTP_422_UNPROCESSABLE_ENTITY)
266+
267+
# update with SAME username (i.e. existing)
268+
url = client.app.router["get_my_profile"].url_for()
269+
resp = await client.get(f"{url}")
270+
data, _ = await assert_status(resp, status.HTTP_200_OK)
271+
272+
url = client.app.router["update_my_profile"].url_for()
273+
resp = await client.patch(
274+
f"{url}",
275+
json={
276+
"userName": data["userName"],
277+
},
278+
)
279+
await assert_status(resp, status.HTTP_409_CONFLICT)
280+
281+
240282
@pytest.fixture
241283
def mock_failing_database_connection(mocker: Mock) -> MagicMock:
242284
"""

0 commit comments

Comments
 (0)