diff --git a/api/specs/web-server/_users.py b/api/specs/web-server/_users.py index 093434bac1c..892045413ac 100644 --- a/api/specs/web-server/_users.py +++ b/api/specs/web-server/_users.py @@ -10,8 +10,10 @@ from models_library.api_schemas_webserver.users import ( MyFunctionPermissionsGet, MyPermissionGet, - MyProfileGet, - MyProfilePatch, + MyPhoneConfirm, + MyPhoneRegister, + MyProfileRestGet, + MyProfileRestPatch, MyTokenCreate, MyTokenGet, TokenPathParams, @@ -36,7 +38,7 @@ @router.get( "/me", - response_model=Envelope[MyProfileGet], + response_model=Envelope[MyProfileRestGet], ) async def get_my_profile(): ... @@ -45,7 +47,58 @@ async def get_my_profile(): ... "/me", status_code=status.HTTP_204_NO_CONTENT, ) -async def update_my_profile(_body: MyProfilePatch): ... +async def update_my_profile(_body: MyProfileRestPatch): ... + + +@router.post( + "/me/phone:register", + description="Starts the phone registration process", + status_code=status.HTTP_202_ACCEPTED, + responses={ + status.HTTP_202_ACCEPTED: {"description": "Phone registration initiated"}, + status.HTTP_401_UNAUTHORIZED: {"description": "Authentication required"}, + status.HTTP_403_FORBIDDEN: {"description": "Insufficient permissions"}, + status.HTTP_422_UNPROCESSABLE_ENTITY: { + "description": "Invalid phone number format" + }, + }, +) +async def my_phone_register(_body: MyPhoneRegister): ... + + +@router.post( + "/me/phone:resend", + description="Resends the phone registration code", + status_code=status.HTTP_202_ACCEPTED, + responses={ + status.HTTP_202_ACCEPTED: {"description": "Phone code resent"}, + status.HTTP_400_BAD_REQUEST: { + "description": "No pending phone registration found" + }, + status.HTTP_401_UNAUTHORIZED: {"description": "Authentication required"}, + status.HTTP_403_FORBIDDEN: {"description": "Insufficient permissions"}, + }, +) +async def my_phone_resend(): ... + + +@router.post( + "/me/phone:confirm", + description="Confirms the phone registration", + status_code=status.HTTP_204_NO_CONTENT, + responses={ + status.HTTP_204_NO_CONTENT: {"description": "Phone registration confirmed"}, + status.HTTP_400_BAD_REQUEST: { + "description": "No pending registration or invalid code" + }, + status.HTTP_401_UNAUTHORIZED: {"description": "Authentication required"}, + status.HTTP_403_FORBIDDEN: {"description": "Insufficient permissions"}, + status.HTTP_422_UNPROCESSABLE_ENTITY: { + "description": "Invalid confirmation code format" + }, + }, +) +async def my_phone_confirm(_body: MyPhoneConfirm): ... @router.patch( diff --git a/packages/models-library/requirements/_base.in b/packages/models-library/requirements/_base.in index b33d20bdd6b..34141532522 100644 --- a/packages/models-library/requirements/_base.in +++ b/packages/models-library/requirements/_base.in @@ -7,6 +7,6 @@ arrow jsonschema orjson -pydantic[email] -pydantic-settings pydantic-extra-types +pydantic-settings +pydantic[email] diff --git a/packages/models-library/src/models_library/api_schemas_webserver/users.py b/packages/models-library/src/models_library/api_schemas_webserver/users.py index ab3d8b18e2d..b81d13ed086 100644 --- a/packages/models-library/src/models_library/api_schemas_webserver/users.py +++ b/packages/models-library/src/models_library/api_schemas_webserver/users.py @@ -62,7 +62,7 @@ class MyProfilePrivacyPatch(InputSchema): hide_email: bool | None = None -class MyProfileGet(OutputSchemaWithoutCamelCase): +class MyProfileRestGet(OutputSchemaWithoutCamelCase): id: UserID user_name: Annotated[ IDStr, Field(description="Unique username identifier", alias="userName") @@ -70,6 +70,7 @@ class MyProfileGet(OutputSchemaWithoutCamelCase): first_name: FirstNameStr | None = None last_name: LastNameStr | None = None login: LowerCaseEmailStr + phone: str | None = None role: Literal["ANONYMOUS", "GUEST", "USER", "TESTER", "PRODUCT_OWNER", "ADMIN"] groups: MyGroupsGet | None = None @@ -141,6 +142,7 @@ def from_domain_model( "last_name", "email", "role", + "phone", "privacy", "expiration_date", }, @@ -155,21 +157,19 @@ def from_domain_model( ) -class MyProfilePatch(InputSchemaWithoutCamelCase): +class MyProfileRestPatch(InputSchemaWithoutCamelCase): first_name: FirstNameStr | None = None last_name: LastNameStr | None = None user_name: Annotated[IDStr | None, Field(alias="userName", min_length=4)] = None + # NOTE: phone is updated via a dedicated endpoint! privacy: MyProfilePrivacyPatch | None = None - model_config = ConfigDict( - json_schema_extra={ - "example": { - "first_name": "Pedro", - "last_name": "Crespo", - } - } - ) + @staticmethod + def _update_json_schema_extra(schema: JsonDict) -> None: + schema.update({"examples": [{"first_name": "Pedro", "last_name": "Crespo"}]}) + + model_config = ConfigDict(json_schema_extra=_update_json_schema_extra) @field_validator("user_name") @classmethod @@ -207,6 +207,27 @@ def _validate_user_name(cls, value: str): return value +# +# PHONE REGISTRATION +# + + +class MyPhoneRegister(InputSchema): + phone: Annotated[ + str, + StringConstraints(strip_whitespace=True, min_length=1), + Field(description="Phone number to register"), + ] + + +class MyPhoneConfirm(InputSchema): + code: Annotated[ + str, + StringConstraints(strip_whitespace=True, pattern=r"^[A-Za-z0-9]+$"), + Field(description="Alphanumeric confirmation code"), + ] + + # # USER # diff --git a/packages/models-library/src/models_library/users.py b/packages/models-library/src/models_library/users.py index 3b8d4344b4b..a4cb18036b5 100644 --- a/packages/models-library/src/models_library/users.py +++ b/packages/models-library/src/models_library/users.py @@ -38,6 +38,7 @@ class MyProfile(BaseModel): email: LowerCaseEmailStr role: UserRole privacy: PrivacyDict + phone: str | None expiration_date: datetime.date | None = None @staticmethod @@ -50,6 +51,7 @@ def _update_json_schema_extra(schema: JsonDict) -> None: "user_name": "PtN5Ab0uv", "first_name": "PtN5Ab0uv", "last_name": "", + "phone": None, "role": "GUEST", "privacy": { "hide_email": True, diff --git a/packages/models-library/tests/test_api_schemas_webserver_users.py b/packages/models-library/tests/test_api_schemas_webserver_users.py index afefb91c481..43375a67e20 100644 --- a/packages/models-library/tests/test_api_schemas_webserver_users.py +++ b/packages/models-library/tests/test_api_schemas_webserver_users.py @@ -8,44 +8,44 @@ import pytest from common_library.users_enums import UserRole from models_library.api_schemas_webserver.users import ( - MyProfileGet, - MyProfilePatch, + MyProfileRestGet, + MyProfileRestPatch, ) 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"]: + for example in MyProfileRestGet.model_json_schema()["examples"]: data = deepcopy(example) data["role"] = user_role - m1 = MyProfileGet(**data) + m1 = MyProfileRestGet(**data) data["role"] = UserRole(user_role) - m2 = MyProfileGet(**data) + m2 = MyProfileRestGet(**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"}) + MyProfileRestPatch.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 + MyProfileRestPatch.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"}) + MyProfileRestPatch.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 + MyProfileRestPatch.model_validate({"userName": "u1234"}) # OK def test_my_profile_patch_username_special_characters(): @@ -53,29 +53,29 @@ def test_my_profile_patch_username_special_characters(): with pytest.raises( ValidationError, match="consecutive special characters" ) as err_info: - MyProfilePatch.model_validate({"userName": "u1__234"}) + MyProfileRestPatch.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 + MyProfileRestPatch.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_"}) + MyProfileRestPatch.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 + MyProfileRestPatch.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"}) + MyProfileRestPatch.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 + MyProfileRestPatch.model_validate({"userName": "midas"}) # OK diff --git a/packages/models-library/tests/test_users.py b/packages/models-library/tests/test_users.py index 4c9d2756934..743ee51135a 100644 --- a/packages/models-library/tests/test_users.py +++ b/packages/models-library/tests/test_users.py @@ -1,4 +1,4 @@ -from models_library.api_schemas_webserver.users import MyProfileGet +from models_library.api_schemas_webserver.users import MyProfileRestGet from models_library.api_schemas_webserver.users_preferences import Preference from models_library.groups import AccessRightsDict, Group, GroupsByTypeTuple from models_library.users import MyProfile @@ -22,6 +22,6 @@ def test_adapter_from_model_to_schema(): ) my_preferences = {"foo": Preference(default_value=3, value=1)} - MyProfileGet.from_domain_model( + MyProfileRestGet.from_domain_model( my_profile, my_groups_by_type, my_product_group, my_preferences ) diff --git a/services/api-server/src/simcore_service_api_server/services_http/webserver.py b/services/api-server/src/simcore_service_api_server/services_http/webserver.py index b4b998b3934..c564c29cbd0 100644 --- a/services/api-server/src/simcore_service_api_server/services_http/webserver.py +++ b/services/api-server/src/simcore_service_api_server/services_http/webserver.py @@ -30,9 +30,9 @@ ProjectInputUpdate, ) from models_library.api_schemas_webserver.resource_usage import PricingPlanGet -from models_library.api_schemas_webserver.users import MyProfileGet as WebProfileGet +from models_library.api_schemas_webserver.users import MyProfileRestGet as WebProfileGet from models_library.api_schemas_webserver.users import ( - MyProfilePatch as WebProfileUpdate, + MyProfileRestPatch as WebProfileUpdate, ) from models_library.api_schemas_webserver.wallets import WalletGet from models_library.generics import Envelope diff --git a/services/api-server/tests/unit/_with_db/test_api_user.py b/services/api-server/tests/unit/_with_db/test_api_user.py index 5b29f72ef15..b255889b339 100644 --- a/services/api-server/tests/unit/_with_db/test_api_user.py +++ b/services/api-server/tests/unit/_with_db/test_api_user.py @@ -9,7 +9,7 @@ import pytest import respx from fastapi import FastAPI -from models_library.api_schemas_webserver.users import MyProfileGet as WebProfileGet +from models_library.api_schemas_webserver.users import MyProfileRestGet as WebProfileGet from pytest_mock import MockType from respx import MockRouter from simcore_service_api_server._meta import API_VTAG diff --git a/services/web/server/VERSION b/services/web/server/VERSION index 5e5d529aada..7375dee5f49 100644 --- a/services/web/server/VERSION +++ b/services/web/server/VERSION @@ -1 +1 @@ -0.71.1 +0.72.0 diff --git a/services/web/server/setup.cfg b/services/web/server/setup.cfg index a7d95433e23..bba1e07bf00 100644 --- a/services/web/server/setup.cfg +++ b/services/web/server/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.71.1 +current_version = 0.72.0 commit = True message = services/webserver api version: {current_version} → {new_version} tag = False @@ -13,13 +13,13 @@ commit_args = --no-verify addopts = --strict-markers asyncio_mode = auto asyncio_default_fixture_loop_scope = function -markers = +markers = slow: marks tests as slow (deselect with '-m "not slow"') acceptance_test: "marks tests as 'acceptance tests' i.e. does the system do what the user expects? Typically those are workflows." testit: "marks test to run during development" heavy_load: "mark tests that require large amount of data" [mypy] -plugins = +plugins = pydantic.mypy sqlalchemy.ext.mypy.plugin diff --git a/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml b/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml index 6bad4a0bb53..ce122ca58cc 100644 --- a/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml +++ b/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml @@ -2,7 +2,7 @@ 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.71.1 + version: 0.72.0 servers: - url: '' description: webserver @@ -1187,7 +1187,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/Envelope_MyProfileGet_' + $ref: '#/components/schemas/Envelope_MyProfileRestGet_' patch: tags: - users @@ -1197,11 +1197,79 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/MyProfilePatch' + $ref: '#/components/schemas/MyProfileRestPatch' required: true responses: '204': description: Successful Response + /v0/me/phone:register: + post: + tags: + - users + summary: My Phone Register + description: Starts the phone registration process + operationId: my_phone_register + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/MyPhoneRegister' + required: true + responses: + '202': + description: Phone registration initiated + content: + application/json: + schema: {} + '401': + description: Authentication required + '403': + description: Insufficient permissions + '422': + description: Invalid phone number format + /v0/me/phone:resend: + post: + tags: + - users + summary: My Phone Resend + description: Resends the phone registration code + operationId: my_phone_resend + responses: + '202': + description: Phone code resent + content: + application/json: + schema: {} + '400': + description: No pending phone registration found + '401': + description: Authentication required + '403': + description: Insufficient permissions + /v0/me/phone:confirm: + post: + tags: + - users + summary: My Phone Confirm + description: Confirms the phone registration + operationId: my_phone_confirm + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/MyPhoneConfirm' + required: true + responses: + '204': + description: Phone registration confirmed + '400': + description: No pending registration or invalid code + '401': + description: Authentication required + '403': + description: Insufficient permissions + '422': + description: Invalid confirmation code format /v0/me/preferences/{preference_id}: patch: tags: @@ -10515,11 +10583,11 @@ components: title: Error type: object title: Envelope[MyGroupsGet] - Envelope_MyProfileGet_: + Envelope_MyProfileRestGet_: properties: data: anyOf: - - $ref: '#/components/schemas/MyProfileGet' + - $ref: '#/components/schemas/MyProfileRestGet' - type: 'null' error: anyOf: @@ -10527,7 +10595,7 @@ components: - type: 'null' title: Error type: object - title: Envelope[MyProfileGet] + title: Envelope[MyProfileRestGet] Envelope_MyTokenGet_: properties: data: @@ -13030,7 +13098,65 @@ components: - name - allowed title: MyPermissionGet - MyProfileGet: + MyPhoneConfirm: + properties: + code: + type: string + pattern: ^[A-Za-z0-9]+$ + title: Code + description: Alphanumeric confirmation code + type: object + required: + - code + title: MyPhoneConfirm + MyPhoneRegister: + properties: + phone: + type: string + minLength: 1 + title: Phone + description: Phone number to register + type: object + required: + - phone + title: MyPhoneRegister + MyProfilePrivacyGet: + properties: + hideUsername: + type: boolean + title: Hideusername + hideFullname: + type: boolean + title: Hidefullname + hideEmail: + type: boolean + title: Hideemail + type: object + required: + - hideUsername + - hideFullname + - hideEmail + title: MyProfilePrivacyGet + MyProfilePrivacyPatch: + properties: + hideUsername: + anyOf: + - type: boolean + - type: 'null' + title: Hideusername + hideFullname: + anyOf: + - type: boolean + - type: 'null' + title: Hidefullname + hideEmail: + anyOf: + - type: boolean + - type: 'null' + title: Hideemail + type: object + title: MyProfilePrivacyPatch + MyProfileRestGet: properties: id: type: integer @@ -13059,6 +13185,11 @@ components: type: string format: email title: Login + phone: + anyOf: + - type: string + - type: 'null' + title: Phone role: type: string enum: @@ -13102,8 +13233,8 @@ components: - role - privacy - preferences - title: MyProfileGet - MyProfilePatch: + title: MyProfileRestGet + MyProfileRestPatch: properties: first_name: anyOf: @@ -13129,46 +13260,7 @@ components: - $ref: '#/components/schemas/MyProfilePrivacyPatch' - type: 'null' type: object - title: MyProfilePatch - example: - first_name: Pedro - last_name: Crespo - MyProfilePrivacyGet: - properties: - hideUsername: - type: boolean - title: Hideusername - hideFullname: - type: boolean - title: Hidefullname - hideEmail: - type: boolean - title: Hideemail - type: object - required: - - hideUsername - - hideFullname - - hideEmail - title: MyProfilePrivacyGet - MyProfilePrivacyPatch: - properties: - hideUsername: - anyOf: - - type: boolean - - type: 'null' - title: Hideusername - hideFullname: - anyOf: - - type: boolean - - type: 'null' - title: Hidefullname - hideEmail: - anyOf: - - type: boolean - - type: 'null' - title: Hideemail - type: object - title: MyProfilePrivacyPatch + title: MyProfileRestPatch MyTokenCreate: properties: service: diff --git a/services/web/server/src/simcore_service_webserver/users/_controller/rest/_rest_exceptions.py b/services/web/server/src/simcore_service_webserver/users/_controller/rest/_rest_exceptions.py index 89b7a94cd57..3fc12fca855 100644 --- a/services/web/server/src/simcore_service_webserver/users/_controller/rest/_rest_exceptions.py +++ b/services/web/server/src/simcore_service_webserver/users/_controller/rest/_rest_exceptions.py @@ -11,6 +11,9 @@ AlreadyPreRegisteredError, MissingGroupExtraPropertiesForProductError, PendingPreRegistrationNotFoundError, + PhoneRegistrationCodeInvalidError, + PhoneRegistrationPendingNotFoundError, + PhoneRegistrationSessionInvalidError, UserNameDuplicateError, UserNotFoundError, ) @@ -54,6 +57,27 @@ _version=1, ), ), + PhoneRegistrationPendingNotFoundError: HttpErrorInfo( + status.HTTP_400_BAD_REQUEST, + user_message( + "No pending phone registration found. Please start the phone registration process first.", + _version=1, + ), + ), + PhoneRegistrationSessionInvalidError: HttpErrorInfo( + status.HTTP_400_BAD_REQUEST, + user_message( + "Your phone registration session is invalid or has expired. Please start the phone registration process again.", + _version=1, + ), + ), + PhoneRegistrationCodeInvalidError: HttpErrorInfo( + status.HTTP_400_BAD_REQUEST, + user_message( + "The confirmation code you entered is incorrect. Please check and try again.", + _version=1, + ), + ), } handle_rest_requests_exceptions = exception_handling_decorator( diff --git a/services/web/server/src/simcore_service_webserver/users/_controller/rest/users_rest.py b/services/web/server/src/simcore_service_webserver/users/_controller/rest/users_rest.py index f45f9433bb9..88a2a03dce9 100644 --- a/services/web/server/src/simcore_service_webserver/users/_controller/rest/users_rest.py +++ b/services/web/server/src/simcore_service_webserver/users/_controller/rest/users_rest.py @@ -3,8 +3,10 @@ from aiohttp import web from models_library.api_schemas_webserver.users import ( - MyProfileGet, - MyProfilePatch, + MyPhoneConfirm, + MyPhoneRegister, + MyProfileRestGet, + MyProfileRestPatch, UserGet, UsersSearch, ) @@ -12,6 +14,9 @@ from servicelib.aiohttp.requests_validation import ( parse_request_body_as, ) +from simcore_service_webserver.application_settings_utils import ( + requires_dev_feature_enabled, +) from ...._meta import API_VTAG from ....groups import api as groups_service @@ -20,13 +25,19 @@ from ....products import products_web from ....products.models import Product from ....security.decorators import permission_required +from ....session.api import get_session from ....utils_aiohttp import envelope_json_response from ... import _users_service +from ..._users_web import RegistrationSessionManager from ._rest_exceptions import handle_rest_requests_exceptions from ._rest_schemas import UsersRequestContext _logger = logging.getLogger(__name__) +_REGISTRATION_CODE_VALUE_FAKE = ( + "123456" # NOTE: temporary fake while developing registration feature +) + routes = web.RouteTableDef() @@ -64,7 +75,7 @@ async def get_my_profile(request: web.Request) -> web.Response: request.app, user_id=req_ctx.user_id, product_name=req_ctx.product_name ) - profile = MyProfileGet.from_domain_model( + profile = MyProfileRestGet.from_domain_model( my_profile, groups_by_type, my_product_group, preferences ) @@ -77,7 +88,7 @@ async def get_my_profile(request: web.Request) -> web.Response: @handle_rest_requests_exceptions async def update_my_profile(request: web.Request) -> web.Response: req_ctx = UsersRequestContext.model_validate(request) - profile_update = await parse_request_body_as(MyProfilePatch, request) + profile_update = await parse_request_body_as(MyProfileRestPatch, request) await _users_service.update_my_profile( request.app, user_id=req_ctx.user_id, update=profile_update @@ -85,6 +96,76 @@ async def update_my_profile(request: web.Request) -> web.Response: return web.json_response(status=status.HTTP_204_NO_CONTENT) +# +# PHONE REGISTRATION: /me/phone:* +# + + +@routes.post(f"/{API_VTAG}/me/phone:register", name="my_phone_register") +@login_required +@permission_required("user.profile.update") +@requires_dev_feature_enabled +@handle_rest_requests_exceptions +async def my_phone_register(request: web.Request) -> web.Response: + req_ctx = UsersRequestContext.model_validate(request) + phone_register = await parse_request_body_as(MyPhoneRegister, request) + + session = await get_session(request) + registration_session_manager = RegistrationSessionManager( + session, req_ctx.user_id, req_ctx.product_name + ) + registration_session_manager.start_registration( + phone_register.phone, code=_REGISTRATION_CODE_VALUE_FAKE + ) + + return web.json_response(status=status.HTTP_202_ACCEPTED) + + +@routes.post(f"/{API_VTAG}/me/phone:resend", name="my_phone_resend") +@login_required +@permission_required("user.profile.update") +@requires_dev_feature_enabled +@handle_rest_requests_exceptions +async def my_phone_resend(request: web.Request) -> web.Response: + req_ctx = UsersRequestContext.model_validate(request) + + session = await get_session(request) + registration_session_manager = RegistrationSessionManager( + session, req_ctx.user_id, req_ctx.product_name + ) + registration_session_manager.regenerate_code(new_code=_REGISTRATION_CODE_VALUE_FAKE) + + return web.json_response(status=status.HTTP_202_ACCEPTED) + + +@routes.post(f"/{API_VTAG}/me/phone:confirm", name="my_phone_confirm") +@login_required +@permission_required("user.profile.update") +@requires_dev_feature_enabled +@handle_rest_requests_exceptions +async def my_phone_confirm(request: web.Request) -> web.Response: + req_ctx = UsersRequestContext.model_validate(request) + phone_confirm = await parse_request_body_as(MyPhoneConfirm, request) + + session = await get_session(request) + registration_session_manager = RegistrationSessionManager( + session, req_ctx.user_id, req_ctx.product_name + ) + + registration = registration_session_manager.validate_pending_registration() + registration_session_manager.validate_confirmation_code(phone_confirm.code) + + await _users_service.update_user_phone( + request.app, + user_id=req_ctx.user_id, + phone=registration["data"], + ) + + registration_session_manager.clear_session() + + return web.json_response(status=status.HTTP_204_NO_CONTENT) + + # # USERS (public) # diff --git a/services/web/server/src/simcore_service_webserver/users/_models.py b/services/web/server/src/simcore_service_webserver/users/_models.py index 967f010d0b0..b9f612d34c5 100644 --- a/services/web/server/src/simcore_service_webserver/users/_models.py +++ b/services/web/server/src/simcore_service_webserver/users/_models.py @@ -1,5 +1,6 @@ from typing import Annotated, Any, NamedTuple, Self, TypedDict +from models_library.api_schemas_webserver.users import MyProfileRestPatch from models_library.basic_types import IDStr from models_library.emails import LowerCaseEmailStr from pydantic import BaseModel, ConfigDict, EmailStr, Field @@ -43,9 +44,9 @@ def flatten_dict(d: dict, parent_key="", sep="_"): return dict(items) -class ToUserUpdateDB(BaseModel): +class UserModelAdapter(BaseModel): """ - Maps ProfileUpdate api-model into UserUpdate db-model + Maps ProfileUpdate api schema into UserUpdate db-model """ # NOTE: field names are UserDB columns @@ -62,13 +63,13 @@ class ToUserUpdateDB(BaseModel): model_config = ConfigDict(extra="forbid") @classmethod - def from_api(cls, profile_update) -> Self: + def from_rest_schema_model(cls, profile_update: MyProfileRestPatch) -> Self: # The mapping of embed fields to flatten keys is done here return cls.model_validate( flatten_dict(profile_update.model_dump(exclude_unset=True, by_alias=False)) ) - def to_db(self) -> dict[str, Any]: + def to_db_values(self) -> dict[str, Any]: return self.model_dump(exclude_unset=True, by_alias=False) diff --git a/services/web/server/src/simcore_service_webserver/users/_users_repository.py b/services/web/server/src/simcore_service_webserver/users/_users_repository.py index b1cdfd33637..8b2f5ec4a08 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_repository.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_repository.py @@ -38,7 +38,7 @@ from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine from ..db.plugin import get_asyncpg_engine -from ._models import FullNameDict, ToUserUpdateDB +from ._models import FullNameDict from .exceptions import ( BillingDetailsNotFoundError, UserNameDuplicateError, @@ -440,6 +440,7 @@ async def get_my_profile(app: web.Application, *, user_id: UserID) -> MyProfile: users.c.last_name, users.c.email, users.c.role, + users.c.phone, sa.func.json_build_object( "hide_username", users.c.privacy_hide_username, @@ -457,7 +458,7 @@ async def get_my_profile(app: web.Application, *, user_id: UserID) -> MyProfile: ).label("expiration_date"), ).where(users.c.id == user_id) ) - row = await result.first() + row = await result.one_or_none() if not row: raise UserNotFoundError(user_id=user_id) @@ -471,16 +472,16 @@ async def update_user_profile( app: web.Application, *, user_id: UserID, - update: ToUserUpdateDB, + updated_values: dict[str, Any], ) -> None: """ Raises: UserNotFoundError UserNameAlreadyExistsError """ - user_id = _parse_as_user(user_id) + if updated_values: + user_id = _parse_as_user(user_id) - if updated_values := update.to_db(): try: async with transaction_context(engine=get_asyncpg_engine(app)) as conn: await conn.execute( diff --git a/services/web/server/src/simcore_service_webserver/users/_users_service.py b/services/web/server/src/simcore_service_webserver/users/_users_service.py index 88364e0a122..fa4a7f28f5b 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_service.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_service.py @@ -3,13 +3,14 @@ import pycountry from aiohttp import web -from models_library.api_schemas_webserver.users import MyProfilePatch +from models_library.api_schemas_webserver.users import MyProfileRestPatch +from models_library.api_schemas_webserver.users_preferences import AggregatedPreferences from models_library.basic_types import IDStr from models_library.emails import LowerCaseEmailStr from models_library.groups import GroupID from models_library.payments import UserInvoiceAddress from models_library.products import ProductName -from models_library.users import UserBillingDetails, UserID, UserPermission +from models_library.users import MyProfile, UserBillingDetails, UserID, UserPermission from pydantic import TypeAdapter from simcore_postgres_database.models.users import UserStatus from simcore_postgres_database.utils_groups_extra_properties import ( @@ -22,10 +23,10 @@ from . import _users_repository from ._models import ( FullNameDict, - ToUserUpdateDB, UserCredentialsTuple, UserDisplayAndIdNamesTuple, UserIdNamesTuple, + UserModelAdapter, ) from .exceptions import ( MissingGroupExtraPropertiesForProductError, @@ -249,7 +250,7 @@ async def update_expired_users(app: web.Application) -> list[UserID]: async def get_my_profile( app: web.Application, *, user_id: UserID, product_name: ProductName -): +) -> tuple[MyProfile, AggregatedPreferences]: """Caller and target user is the same. Privacy settings do not apply here :raises UserNotFoundError: @@ -276,11 +277,31 @@ async def update_my_profile( app: web.Application, *, user_id: UserID, - update: MyProfilePatch, + update: MyProfileRestPatch, ) -> None: await _users_repository.update_user_profile( app, user_id=user_id, - update=ToUserUpdateDB.from_api(update), + updated_values=UserModelAdapter.from_rest_schema_model(update).to_db_values(), + ) + + +async def update_user_phone( + app: web.Application, + *, + user_id: UserID, + phone: str, +) -> None: + """Update user's phone number after successful verification + + Args: + app: Web application instance + user_id: ID of the user whose phone to update + phone: Verified phone number to set + """ + await _users_repository.update_user_profile( + app, + user_id=user_id, + updated_values={"phone": phone}, ) diff --git a/services/web/server/src/simcore_service_webserver/users/_users_web.py b/services/web/server/src/simcore_service_webserver/users/_users_web.py new file mode 100644 index 00000000000..91df2de62fb --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/users/_users_web.py @@ -0,0 +1,73 @@ +import logging +from typing import Literal, TypedDict + +from aiohttp_session import Session +from models_library.users import UserID + +from .exceptions import ( + PhoneRegistrationCodeInvalidError, + PhoneRegistrationPendingNotFoundError, + PhoneRegistrationSessionInvalidError, +) + +_logger = logging.getLogger(__name__) + +# Registration session keys +_REGISTRATION_KEY = "registration" +_REGISTRATION_PENDING_KEY = "registration_pending" +_REGISTRATION_CODE_KEY = "registration_code" + + +class RegistrationData(TypedDict): + """Registration session data structure.""" + + user_id: UserID + data: str + status: Literal["pending_confirmation"] + + +class RegistrationSessionManager: + def __init__(self, session: Session, user_id: UserID, product_name: str): + self._session = session + self._user_id = user_id + self._product_name = product_name + + def start_registration(self, data: str, code: str) -> None: + registration_data: RegistrationData = { + "user_id": self._user_id, + "data": data, # keep data + "status": "pending_confirmation", + } + self._session[_REGISTRATION_KEY] = registration_data + self._session[_REGISTRATION_CODE_KEY] = code + self._session[_REGISTRATION_PENDING_KEY] = True + + def validate_pending_registration(self) -> RegistrationData: + if not self._session.get(_REGISTRATION_PENDING_KEY): + raise PhoneRegistrationPendingNotFoundError( + user_id=self._user_id, product_name=self._product_name + ) + + registration: RegistrationData | None = self._session.get(_REGISTRATION_KEY) + if not registration or registration["user_id"] != self._user_id: + raise PhoneRegistrationSessionInvalidError( + user_id=self._user_id, product_name=self._product_name + ) + + return registration + + def regenerate_code(self, new_code: str) -> None: + self.validate_pending_registration() + self._session[_REGISTRATION_CODE_KEY] = new_code + + def validate_confirmation_code(self, provided_code: str) -> None: + expected_code = self._session.get(_REGISTRATION_CODE_KEY) + if not expected_code or provided_code != expected_code: + raise PhoneRegistrationCodeInvalidError( + user_id=self._user_id, product_name=self._product_name + ) + + def clear_session(self) -> None: + self._session.pop(_REGISTRATION_KEY, None) + self._session.pop(_REGISTRATION_PENDING_KEY, None) + self._session.pop(_REGISTRATION_CODE_KEY, None) diff --git a/services/web/server/src/simcore_service_webserver/users/exceptions.py b/services/web/server/src/simcore_service_webserver/users/exceptions.py index b1533222195..a71472b8374 100644 --- a/services/web/server/src/simcore_service_webserver/users/exceptions.py +++ b/services/web/server/src/simcore_service_webserver/users/exceptions.py @@ -75,3 +75,15 @@ def __init__(self, *, email: str, product_name: str, **ctx: Any): super().__init__(**ctx) self.email = email self.product_name = product_name + + +class PhoneRegistrationPendingNotFoundError(UsersBaseError): + msg_template = "No pending phone registration found" + + +class PhoneRegistrationSessionInvalidError(UsersBaseError): + msg_template = "Invalid phone registration session" + + +class PhoneRegistrationCodeInvalidError(UsersBaseError): + msg_template = "Invalid confirmation code" diff --git a/services/web/server/tests/unit/isolated/test_users_models.py b/services/web/server/tests/unit/isolated/test_users_models.py index f11385225cf..48b621c51f5 100644 --- a/services/web/server/tests/unit/isolated/test_users_models.py +++ b/services/web/server/tests/unit/isolated/test_users_models.py @@ -10,23 +10,23 @@ import pytest from faker import Faker from models_library.api_schemas_webserver.users import ( - MyProfileGet, - MyProfilePatch, MyProfilePrivacyGet, + MyProfileRestGet, + MyProfileRestPatch, ) from models_library.generics import Envelope from models_library.utils.fastapi_encoders import jsonable_encoder from servicelib.rest_constants import RESPONSE_MODEL_POLICY from simcore_postgres_database import utils_users -from simcore_service_webserver.users._models import ToUserUpdateDB +from simcore_service_webserver.users._models import UserModelAdapter @pytest.fixture -def fake_profile_get(faker: Faker) -> MyProfileGet: +def fake_profile_get(faker: Faker) -> MyProfileRestGet: fake_profile: dict[str, Any] = faker.simple_profile() first, last = fake_profile["name"].rsplit(maxsplit=1) - return MyProfileGet( + return MyProfileRestGet( id=faker.pyint(), first_name=first, last_name=last, @@ -40,7 +40,7 @@ def fake_profile_get(faker: Faker) -> MyProfileGet: ) -def test_profile_get_expiration_date(fake_profile_get: MyProfileGet): +def test_profile_get_expiration_date(fake_profile_get: MyProfileRestGet): fake_expiration = datetime.now(UTC) profile = fake_profile_get.model_copy( @@ -53,7 +53,7 @@ def test_profile_get_expiration_date(fake_profile_get: MyProfileGet): assert body["expirationDate"] == fake_expiration.date().isoformat() -def test_auto_compute_gravatar__deprecated(fake_profile_get: MyProfileGet): +def test_auto_compute_gravatar__deprecated(fake_profile_get: MyProfileRestGet): profile = fake_profile_get.model_copy() @@ -62,7 +62,7 @@ def test_auto_compute_gravatar__deprecated(fake_profile_get: MyProfileGet): assert ( "gravatar_id" not in data - ), f"{dict(MyProfileGet.model_fields)['gravatar_id'].deprecated=}" + ), f"{dict(MyProfileRestGet.model_fields)['gravatar_id'].deprecated=}" assert data["id"] == profile.id assert data["first_name"] == profile.first_name assert data["last_name"] == profile.last_name @@ -116,13 +116,13 @@ def test_parsing_output_of_get_user_profile(): }, } - profile = MyProfileGet.model_validate(result_from_db_query_and_composition) + profile = MyProfileRestGet.model_validate(result_from_db_query_and_composition) assert "password" not in profile.model_dump(exclude_unset=True) def test_mapping_update_models_from_rest_to_db(): - profile_update = MyProfilePatch.model_validate( + profile_update = MyProfileRestPatch.model_validate( # request payload { "first_name": "foo", @@ -132,10 +132,10 @@ def test_mapping_update_models_from_rest_to_db(): ) # to db - profile_update_db = ToUserUpdateDB.from_api(profile_update) + profile_update_db = UserModelAdapter.from_rest_schema_model(profile_update) # expected - assert profile_update_db.to_db() == { + assert profile_update_db.to_db_values() == { "first_name": "foo", "name": "foo1234", "privacy_hide_fullname": False, @@ -146,7 +146,7 @@ def test_mapping_update_models_from_rest_to_db(): def test_utils_user_generates_valid_myprofile_patch(): username = utils_users._generate_username_from_email("xi@email.com") # noqa: SLF001 - MyProfilePatch.model_validate({"userName": username}) - MyProfilePatch.model_validate( + MyProfileRestPatch.model_validate({"userName": username}) + MyProfileRestPatch.model_validate( {"userName": utils_users.generate_alternative_username(username)} ) diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_registration.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_registration.py index 1262b78ed97..ea7bbae817e 100644 --- a/services/web/server/tests/unit/with_dbs/03/login/test_login_registration.py +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_registration.py @@ -9,7 +9,7 @@ import pytest from aiohttp.test_utils import TestClient from faker import Faker -from models_library.api_schemas_webserver.users import MyProfileGet +from models_library.api_schemas_webserver.users import MyProfileRestGet from models_library.products import ProductName from pytest_mock import MockerFixture from pytest_simcore.helpers.assert_checks import assert_error, assert_status @@ -494,7 +494,7 @@ async def test_registraton_with_invitation_for_trial_account( url = client.app.router["get_my_profile"].url_for() response = await client.get(url.path) data, _ = await assert_status(response, status.HTTP_200_OK) - profile = MyProfileGet.model_validate(data) + profile = MyProfileRestGet.model_validate(data) expected = invitation.user["created_at"] + timedelta(days=TRIAL_DAYS) assert profile.expiration_date diff --git a/services/web/server/tests/unit/with_dbs/03/test_users_rest_profiles.py b/services/web/server/tests/unit/with_dbs/03/test_users_rest_profiles.py index 8b8779e39b4..3123f9f98e1 100644 --- a/services/web/server/tests/unit/with_dbs/03/test_users_rest_profiles.py +++ b/services/web/server/tests/unit/with_dbs/03/test_users_rest_profiles.py @@ -17,9 +17,10 @@ from aiohttp.test_utils import TestClient from aiopg.sa.connection import SAConnection from common_library.users_enums import UserRole +from faker import Faker from models_library.api_schemas_webserver.groups import GroupUserGet from models_library.api_schemas_webserver.users import ( - MyProfileGet, + MyProfileRestGet, UserGet, ) from psycopg2 import OperationalError @@ -35,6 +36,9 @@ from simcore_service_webserver.user_preferences._service import ( get_frontend_user_preferences_aggregation, ) +from simcore_service_webserver.users._controller.rest.users_rest import ( + _REGISTRATION_CODE_VALUE_FAKE, +) from sqlalchemy.exc import OperationalError as SQLAlchemyOperationalError from sqlalchemy.ext.asyncio import AsyncConnection @@ -49,6 +53,7 @@ def app_environment( { "WEBSERVER_GARBAGE_COLLECTOR": "null", "WEBSERVER_DB_LISTENER": "0", + "WEBSERVER_DEV_FEATURES_ENABLED": "1", # NOTE: still under development }, ) @@ -382,7 +387,7 @@ async def test_get_profile( data, error = await assert_status(resp, status.HTTP_200_OK) assert not error - profile = MyProfileGet.model_validate(data) + profile = MyProfileRestGet.model_validate(data) assert profile.login == logged_user["email"] assert profile.first_name == logged_user.get("first_name", None) @@ -464,7 +469,7 @@ async def test_profile_workflow( url = client.app.router["get_my_profile"].url_for() resp = await client.get(f"{url}") data, _ = await assert_status(resp, status.HTTP_200_OK) - my_profile = MyProfileGet.model_validate(data) + my_profile = MyProfileRestGet.model_validate(data) url = client.app.router["update_my_profile"].url_for() resp = await client.patch( @@ -480,7 +485,7 @@ async def test_profile_workflow( url = client.app.router["get_my_profile"].url_for() resp = await client.get(f"{url}") data, _ = await assert_status(resp, status.HTTP_200_OK) - updated_profile = MyProfileGet.model_validate(data) + updated_profile = MyProfileRestGet.model_validate(data) assert updated_profile.first_name != my_profile.first_name assert updated_profile.last_name == my_profile.last_name @@ -590,3 +595,456 @@ async def test_get_profile_with_failing_db_connection( data, error = await assert_status(resp, expected) assert not data assert error["message"] == "Authentication service is temporary unavailable" + + +@pytest.mark.parametrize("user_role", [UserRole.USER]) +async def test_phone_registration_basic_workflow( + user_role: UserRole, + logged_user: UserInfoDict, + client: TestClient, + faker: Faker, +): + assert client.app + + # GET initial profile + url = client.app.router["get_my_profile"].url_for() + resp = await client.get(f"{url}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + + initial_profile = MyProfileRestGet.model_validate(data) + initial_phone = initial_profile.phone + + # REGISTER phone number + new_phone = faker.phone_number() + url = client.app.router["my_phone_register"].url_for() + resp = await client.post( + f"{url}", + json={ + "phone": new_phone, + }, + ) + await assert_status(resp, status.HTTP_202_ACCEPTED) + + # CONFIRM phone registration + url = client.app.router["my_phone_confirm"].url_for() + resp = await client.post( + f"{url}", + json={ + "code": _REGISTRATION_CODE_VALUE_FAKE, + }, + ) + await assert_status(resp, status.HTTP_204_NO_CONTENT) + + # GET updated profile + url = client.app.router["get_my_profile"].url_for() + resp = await client.get(f"{url}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + + updated_profile = MyProfileRestGet.model_validate(data) + + # Verify phone was updated + assert updated_profile.phone == new_phone + assert updated_profile.phone != initial_phone + + # Verify other fields remained unchanged + assert updated_profile.first_name == initial_profile.first_name + assert updated_profile.last_name == initial_profile.last_name + assert updated_profile.login == initial_profile.login + assert updated_profile.user_name == initial_profile.user_name + + +@pytest.mark.parametrize("user_role", [UserRole.USER]) +async def test_phone_registration_workflow( + user_role: UserRole, + logged_user: UserInfoDict, + client: TestClient, + faker: Faker, +): + assert client.app + + # GET initial profile + url = client.app.router["get_my_profile"].url_for() + resp = await client.get(f"{url}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + + initial_profile = MyProfileRestGet.model_validate(data) + initial_phone = initial_profile.phone + + # STEP 1: REGISTER phone number + new_phone = faker.phone_number() + url = client.app.router["my_phone_register"].url_for() + resp = await client.post( + f"{url}", + json={ + "phone": new_phone, + }, + ) + await assert_status(resp, status.HTTP_202_ACCEPTED) + + # STEP 2: CONFIRM phone registration + url = client.app.router["my_phone_confirm"].url_for() + resp = await client.post( + f"{url}", + json={ + "code": _REGISTRATION_CODE_VALUE_FAKE, + }, + ) + await assert_status(resp, status.HTTP_204_NO_CONTENT) + + # GET updated profile + url = client.app.router["get_my_profile"].url_for() + resp = await client.get(f"{url}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + + updated_profile = MyProfileRestGet.model_validate(data) + + # Verify phone was updated + assert updated_profile.phone == new_phone + assert updated_profile.phone != initial_phone + + # Verify other fields remained unchanged + assert updated_profile.first_name == initial_profile.first_name + assert updated_profile.last_name == initial_profile.last_name + assert updated_profile.login == initial_profile.login + assert updated_profile.user_name == initial_profile.user_name + + +@pytest.mark.parametrize("user_role", [UserRole.USER]) +async def test_phone_registration_with_resend( + user_role: UserRole, + logged_user: UserInfoDict, + client: TestClient, + faker: Faker, +): + assert client.app + + # STEP 1: REGISTER phone number + new_phone = faker.phone_number() + url = client.app.router["my_phone_register"].url_for() + resp = await client.post( + f"{url}", + json={ + "phone": new_phone, + }, + ) + await assert_status(resp, status.HTTP_202_ACCEPTED) + + # STEP 2: RESEND code (optional step) + url = client.app.router["my_phone_resend"].url_for() + resp = await client.post(f"{url}") + await assert_status(resp, status.HTTP_202_ACCEPTED) + + # STEP 3: CONFIRM phone registration + url = client.app.router["my_phone_confirm"].url_for() + resp = await client.post( + f"{url}", + json={ + "code": _REGISTRATION_CODE_VALUE_FAKE, + }, + ) + await assert_status(resp, status.HTTP_204_NO_CONTENT) + + # GET updated profile + url = client.app.router["get_my_profile"].url_for() + resp = await client.get(f"{url}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + + updated_profile = MyProfileRestGet.model_validate(data) + + # Verify phone was updated + assert updated_profile.phone == new_phone + + +@pytest.mark.parametrize("user_role", [UserRole.USER]) +async def test_phone_registration_change_existing_phone( + user_role: UserRole, + logged_user: UserInfoDict, + client: TestClient, + faker: Faker, +): + assert client.app + + # Set initial phone + first_phone = faker.phone_number() + url = client.app.router["my_phone_register"].url_for() + resp = await client.post( + f"{url}", + json={ + "phone": first_phone, + }, + ) + await assert_status(resp, status.HTTP_202_ACCEPTED) + + url = client.app.router["my_phone_confirm"].url_for() + resp = await client.post( + f"{url}", + json={ + "code": _REGISTRATION_CODE_VALUE_FAKE, + }, + ) + await assert_status(resp, status.HTTP_204_NO_CONTENT) + + # Change to new phone + new_phone = faker.phone_number() + url = client.app.router["my_phone_register"].url_for() + resp = await client.post( + f"{url}", + json={ + "phone": new_phone, + }, + ) + await assert_status(resp, status.HTTP_202_ACCEPTED) + + url = client.app.router["my_phone_confirm"].url_for() + resp = await client.post( + f"{url}", + json={ + "code": _REGISTRATION_CODE_VALUE_FAKE, + }, + ) + await assert_status(resp, status.HTTP_204_NO_CONTENT) + + # GET updated profile + url = client.app.router["get_my_profile"].url_for() + resp = await client.get(f"{url}") + data, _ = await assert_status(resp, status.HTTP_200_OK) + + updated_profile = MyProfileRestGet.model_validate(data) + + # Verify phone was updated to new phone + assert updated_profile.phone == new_phone + assert updated_profile.phone != first_phone + + +# +# PHONE REGISTRATION FAILURE TESTS +# + + +@pytest.mark.parametrize("user_role", [UserRole.USER]) +async def test_phone_resend_without_pending_registration( + user_role: UserRole, + logged_user: UserInfoDict, + client: TestClient, +): + assert client.app + + # Try to resend code without any pending registration + url = client.app.router["my_phone_resend"].url_for() + resp = await client.post(f"{url}") + await assert_status(resp, status.HTTP_400_BAD_REQUEST) + + +@pytest.mark.parametrize("user_role", [UserRole.USER]) +async def test_phone_confirm_without_pending_registration( + user_role: UserRole, + logged_user: UserInfoDict, + client: TestClient, +): + assert client.app + + # Try to confirm code without any pending registration + url = client.app.router["my_phone_confirm"].url_for() + resp = await client.post( + f"{url}", + json={ + "code": _REGISTRATION_CODE_VALUE_FAKE, + }, + ) + await assert_status(resp, status.HTTP_400_BAD_REQUEST) + + +@pytest.mark.parametrize("user_role", [UserRole.USER]) +async def test_phone_confirm_with_wrong_code( + user_role: UserRole, + logged_user: UserInfoDict, + client: TestClient, + faker: Faker, +): + assert client.app + + # STEP 1: REGISTER phone number + new_phone = faker.phone_number() + url = client.app.router["my_phone_register"].url_for() + resp = await client.post( + f"{url}", + json={ + "phone": new_phone, + }, + ) + await assert_status(resp, status.HTTP_202_ACCEPTED) + + # STEP 2: Try to confirm with wrong code + url = client.app.router["my_phone_confirm"].url_for() + resp = await client.post( + f"{url}", + json={ + "code": "wrongcode1234", + }, + ) + await assert_status(resp, status.HTTP_400_BAD_REQUEST) + + +@pytest.mark.parametrize("user_role", [UserRole.USER]) +async def test_phone_confirm_with_invalid_code_format( + user_role: UserRole, + logged_user: UserInfoDict, + client: TestClient, + faker: Faker, +): + assert client.app + + # STEP 1: REGISTER phone number + new_phone = faker.phone_number() + url = client.app.router["my_phone_register"].url_for() + resp = await client.post( + f"{url}", + json={ + "phone": new_phone, + }, + ) + await assert_status(resp, status.HTTP_202_ACCEPTED) + + # STEP 2: Try to confirm with invalid code format (contains special characters) + url = client.app.router["my_phone_confirm"].url_for() + resp = await client.post( + f"{url}", + json={ + "code": "123-456", # Invalid format according to pattern + }, + ) + await assert_status(resp, status.HTTP_422_UNPROCESSABLE_ENTITY) + + +@pytest.mark.parametrize("user_role", [UserRole.USER]) +async def test_phone_register_with_empty_phone( + user_role: UserRole, + logged_user: UserInfoDict, + client: TestClient, +): + assert client.app + + # Try to register with empty phone number + url = client.app.router["my_phone_register"].url_for() + resp = await client.post( + f"{url}", + json={ + "phone": "", # Empty phone number + }, + ) + await assert_status(resp, status.HTTP_422_UNPROCESSABLE_ENTITY) + + # Try to register with whitespace-only phone number + url = client.app.router["my_phone_register"].url_for() + resp = await client.post( + f"{url}", + json={ + "phone": " ", # Whitespace only + }, + ) + await assert_status(resp, status.HTTP_422_UNPROCESSABLE_ENTITY) + + +@pytest.mark.parametrize("user_role", [UserRole.USER]) +async def test_phone_confirm_with_empty_code( + user_role: UserRole, + logged_user: UserInfoDict, + client: TestClient, + faker: Faker, +): + assert client.app + + # STEP 1: REGISTER phone number + new_phone = faker.phone_number() + url = client.app.router["my_phone_register"].url_for() + resp = await client.post( + f"{url}", + json={ + "phone": new_phone, + }, + ) + await assert_status(resp, status.HTTP_202_ACCEPTED) + + # STEP 2: Try to confirm with empty code + url = client.app.router["my_phone_confirm"].url_for() + resp = await client.post( + f"{url}", + json={ + "code": "", # Empty code + }, + ) + await assert_status(resp, status.HTTP_422_UNPROCESSABLE_ENTITY) + + +@pytest.mark.parametrize( + "user_role,expected", + [ + (UserRole.ANONYMOUS, status.HTTP_401_UNAUTHORIZED), + (UserRole.GUEST, status.HTTP_403_FORBIDDEN), + ], +) +async def test_phone_register_access_rights( + user_role: UserRole, + logged_user: UserInfoDict, + client: TestClient, + expected: HTTPStatus, + faker: Faker, +): + assert client.app + + # Try to register phone with insufficient permissions + url = client.app.router["my_phone_register"].url_for() + resp = await client.post( + f"{url}", + json={ + "phone": faker.phone_number(), + }, + ) + await assert_status(resp, expected) + + +@pytest.mark.parametrize( + "user_role,expected", + [ + (UserRole.ANONYMOUS, status.HTTP_401_UNAUTHORIZED), + (UserRole.GUEST, status.HTTP_403_FORBIDDEN), + ], +) +async def test_phone_resend_access_rights( + user_role: UserRole, + logged_user: UserInfoDict, + client: TestClient, + expected: HTTPStatus, +): + assert client.app + + # Try to resend code with insufficient permissions + url = client.app.router["my_phone_resend"].url_for() + resp = await client.post(f"{url}") + await assert_status(resp, expected) + + +@pytest.mark.parametrize( + "user_role,expected", + [ + (UserRole.ANONYMOUS, status.HTTP_401_UNAUTHORIZED), + (UserRole.GUEST, status.HTTP_403_FORBIDDEN), + ], +) +async def test_phone_confirm_access_rights( + user_role: UserRole, + logged_user: UserInfoDict, + client: TestClient, + expected: HTTPStatus, +): + assert client.app + + # Try to confirm code with insufficient permissions + url = client.app.router["my_phone_confirm"].url_for() + resp = await client.post( + f"{url}", + json={ + "code": _REGISTRATION_CODE_VALUE_FAKE, + }, + ) + await assert_status(resp, expected)