diff --git a/api/specs/web-server/_users.py b/api/specs/web-server/_users.py index 892045413ac..09d56fd1982 100644 --- a/api/specs/web-server/_users.py +++ b/api/specs/web-server/_users.py @@ -10,8 +10,6 @@ from models_library.api_schemas_webserver.users import ( MyFunctionPermissionsGet, MyPermissionGet, - MyPhoneConfirm, - MyPhoneRegister, MyProfileRestGet, MyProfileRestPatch, MyTokenCreate, @@ -32,6 +30,10 @@ UserNotificationCreate, UserNotificationPatch, ) +from simcore_service_webserver.users._controller.rest._rest_schemas import ( + MyPhoneConfirm, + MyPhoneRegister, +) router = APIRouter(prefix=f"/{API_VTAG}", tags=["users"]) diff --git a/api/specs/web-server/_users_admin.py b/api/specs/web-server/_users_admin.py index 3bec7092c02..2e4ff647c3c 100644 --- a/api/specs/web-server/_users_admin.py +++ b/api/specs/web-server/_users_admin.py @@ -19,7 +19,7 @@ from models_library.generics import Envelope from models_library.rest_pagination import Page from simcore_service_webserver._meta import API_VTAG -from simcore_service_webserver.users.schemas import PreRegisteredUserGet +from simcore_service_webserver.users.schemas import UserAccountRestPreRegister router = APIRouter(prefix=f"/{API_VTAG}", tags=["users"]) @@ -69,4 +69,4 @@ async def search_user_accounts( response_model=Envelope[UserAccountGet], tags=_extra_tags, ) -async def pre_register_user_account(_body: PreRegisteredUserGet): ... +async def pre_register_user_account(_body: UserAccountRestPreRegister): ... diff --git a/packages/models-library/src/models_library/api_schemas_webserver/auth.py b/packages/models-library/src/models_library/api_schemas_webserver/auth.py index a77dfe150b3..ccf17cb3a20 100644 --- a/packages/models-library/src/models_library/api_schemas_webserver/auth.py +++ b/packages/models-library/src/models_library/api_schemas_webserver/auth.py @@ -5,6 +5,7 @@ from models_library.rest_base import RequestParameters from pydantic import AliasGenerator, ConfigDict, Field, HttpUrl, SecretStr from pydantic.alias_generators import to_camel +from pydantic.config import JsonDict from ..emails import LowerCaseEmailStr from ._base import InputSchema, OutputSchema @@ -14,33 +15,39 @@ class AccountRequestInfo(InputSchema): form: dict[str, Any] captcha: str + @staticmethod + def _update_json_schema_extra(schema: JsonDict) -> None: + schema.update( + { + "example": { + "form": { + "firstName": "James", + "lastName": "Maxwel", + "email": "maxwel@email.com", + "phone": "+41 44 245 96 96", + "company": "EM Com", + "address": "Infinite Loop", + "city": "Washington", + "postalCode": "98001", + "country": "Switzerland", + "application": "Antenna_Design", + "description": "Description of something", + "hear": "Search_Engine", + "privacyPolicy": True, + "eula": True, + }, + "captcha": "A12B34", + } + } + ) + model_config = ConfigDict( str_strip_whitespace=True, str_max_length=200, # NOTE: this is just informative. The format of the form is defined # currently in the front-end and it might change # SEE image in https://github.com/ITISFoundation/osparc-simcore/pull/5378 - json_schema_extra={ - "example": { - "form": { - "firstName": "James", - "lastName": "Maxwel", - "email": "maxwel@email.com", - "phone": "+1 123456789", - "company": "EM Com", - "address": "Infinite Loop", - "city": "Washington", - "postalCode": "98001", - "country": "USA", - "application": "Antenna_Design", - "description": "Description of something", - "hear": "Search_Engine", - "privacyPolicy": True, - "eula": True, - }, - "captcha": "A12B34", - } - }, + json_schema_extra=_update_json_schema_extra, ) 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 b81d13ed086..89953ae1d81 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 @@ -207,27 +207,6 @@ 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/rest_error.py b/packages/models-library/src/models_library/rest_error.py index 81631e7b608..ce85977c049 100644 --- a/packages/models-library/src/models_library/rest_error.py +++ b/packages/models-library/src/models_library/rest_error.py @@ -126,6 +126,7 @@ def _update_json_schema_extra(schema: JsonDict) -> None: class EnvelopedError(Envelope[None]): + # SEE https://github.com/ITISFoundation/osparc-simcore/issues/443 error: ErrorGet model_config = ConfigDict( diff --git a/packages/models-library/tests/test_users.py b/packages/models-library/tests/test_users.py index 743ee51135a..47678a2ad7c 100644 --- a/packages/models-library/tests/test_users.py +++ b/packages/models-library/tests/test_users.py @@ -1,4 +1,6 @@ -from models_library.api_schemas_webserver.users import MyProfileRestGet +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 diff --git a/packages/pytest-simcore/src/pytest_simcore/helpers/faker_factories.py b/packages/pytest-simcore/src/pytest_simcore/helpers/faker_factories.py index 5aeb6c3be66..5a9b1a5a5d1 100644 --- a/packages/pytest-simcore/src/pytest_simcore/helpers/faker_factories.py +++ b/packages/pytest-simcore/src/pytest_simcore/helpers/faker_factories.py @@ -20,10 +20,9 @@ from uuid import uuid4 import arrow -import faker from faker import Faker -DEFAULT_FAKER: Final = faker.Faker() +DEFAULT_FAKER: Final = Faker() def random_icon_url(fake: Faker): @@ -34,6 +33,15 @@ def random_thumbnail_url(fake: Faker): return fake.image_url(width=32, height=32) +def random_phone_number(fake: Faker = DEFAULT_FAKER) -> str: + # NOTE: faker.phone_number() does not validate with `phonenumbers` library. + phone = fake.random_element( + ["+41763456789", "+19104630364", "+13013044567", "+34 950 453 837"] + ) + tail = f"{fake.pyint(100, 999)}" + return phone[: -len(tail)] + tail # ensure phone keeps its length + + def _compute_hash(password: str) -> str: try: # 'passlib' will be used only if already installed. @@ -105,7 +113,7 @@ def random_pre_registration_details( "pre_first_name": fake.first_name(), "pre_last_name": fake.last_name(), "pre_email": fake.email(), - "pre_phone": fake.phone_number(), + "pre_phone": random_phone_number(fake), "institution": fake.company(), "address": fake.address().replace("\n", ", "), "city": fake.city(), diff --git a/packages/service-library/src/servicelib/aiohttp/requests_validation.py b/packages/service-library/src/servicelib/aiohttp/requests_validation.py index 08892a46c74..dc6819f9dd9 100644 --- a/packages/service-library/src/servicelib/aiohttp/requests_validation.py +++ b/packages/service-library/src/servicelib/aiohttp/requests_validation.py @@ -13,7 +13,8 @@ from typing import TypeVar from aiohttp import web -from common_library.json_serialization import json_dumps +from common_library.user_messages import user_message +from models_library.rest_error import EnvelopedError from pydantic import BaseModel, TypeAdapter, ValidationError from ..mimetype_constants import MIMETYPE_APPLICATION_JSON @@ -25,14 +26,13 @@ @contextmanager def handle_validation_as_http_error( - *, error_msg_template: str, resource_name: str, use_error_v1: bool + *, error_msg_template: str, resource_name: str ) -> Iterator[None]: """Context manager to handle ValidationError and reraise them as HTTPUnprocessableEntity error Arguments: error_msg_template -- _description_ resource_name -- - use_error_v1 -- If True, it uses new error response Raises: web.HTTPUnprocessableEntity: (422) raised from a ValidationError @@ -43,49 +43,37 @@ def handle_validation_as_http_error( yield except ValidationError as err: - details = [ + # SEE https://github.com/ITISFoundation/osparc-simcore/issues/443 + _details = [ { - "loc": ".".join(map(str, e["loc"])), + "loc": ".".join(map(str, e["loc"])), # e.g. "body.name" "msg": e["msg"], "type": e["type"], } for e in err.errors() ] - user_error_message = error_msg_template.format( - failed=", ".join(d["loc"] for d in details) - ) - - if use_error_v1: - # NOTE: keeps backwards compatibility until ligher error response is implemented in the entire API - # Implements servicelib.aiohttp.rest_responses.ErrorItemType - errors = [ - { - "code": e["type"], - "message": e["msg"], - "resource": resource_name, - "field": e["loc"], - } - for e in details - ] - error_json_str = json_dumps( - { - "error": { - "status": status.HTTP_422_UNPROCESSABLE_ENTITY, - "errors": errors, - } - } - ) - else: - # NEW proposed error for https://github.com/ITISFoundation/osparc-simcore/issues/443 - error_json_str = json_dumps( - { - "error": { - "msg": user_error_message, - "resource": resource_name, # optional - "details": details, # optional - } + + errors_details = [ + { + "code": e["type"], + "message": e["msg"], + "resource": resource_name, + "field": e["loc"], + } + for e in _details + ] + + error_json_str = EnvelopedError.model_validate( + { + "error": { + "message": error_msg_template.format( + failed=", ".join(e["field"] for e in errors_details) + ), + "status": status.HTTP_422_UNPROCESSABLE_ENTITY, + "errors": errors_details, } - ) + } + ).model_dump_json(exclude_unset=True, exclude_none=True) raise web.HTTPUnprocessableEntity( # 422 text=error_json_str, @@ -104,15 +92,10 @@ def handle_validation_as_http_error( def parse_request_path_parameters_as( parameters_schema_cls: type[ModelClass], request: web.Request, - *, - use_enveloped_error_v1: bool = True, ) -> ModelClass: """Parses path parameters from 'request' and validates against 'parameters_schema' - Keyword Arguments: - use_enveloped_error_v1 -- new enveloped error model (default: {True}) - Raises: web.HTTPUnprocessableEntity: (422) if validation of parameters fail @@ -121,9 +104,10 @@ def parse_request_path_parameters_as( """ with handle_validation_as_http_error( - error_msg_template="Invalid parameter/s '{failed}' in request path", + error_msg_template=user_message( + "Invalid parameter/s '{failed}' in request path" + ), resource_name=request.rel_url.path, - use_error_v1=use_enveloped_error_v1, ): data = dict(request.match_info) return parameters_schema_cls.model_validate(data) @@ -132,15 +116,10 @@ def parse_request_path_parameters_as( def parse_request_query_parameters_as( parameters_schema_cls: type[ModelClass], request: web.Request, - *, - use_enveloped_error_v1: bool = True, ) -> ModelClass: """Parses query parameters from 'request' and validates against 'parameters_schema' - Keyword Arguments: - use_enveloped_error_v1 -- new enveloped error model (default: {True}) - Raises: web.HTTPUnprocessableEntity: (422) if validation of parameters fail @@ -149,9 +128,10 @@ def parse_request_query_parameters_as( """ with handle_validation_as_http_error( - error_msg_template="Invalid parameter/s '{failed}' in request query", + error_msg_template=user_message( + "Invalid parameter/s '{failed}' in request query" + ), resource_name=request.rel_url.path, - use_error_v1=use_enveloped_error_v1, ): # NOTE: Currently, this does not take into consideration cases where there are multiple # query parameters with the same key. However, we are not using such cases anywhere at the moment. @@ -166,13 +146,12 @@ def parse_request_query_parameters_as( def parse_request_headers_as( parameters_schema_cls: type[ModelClass], request: web.Request, - *, - use_enveloped_error_v1: bool = True, ) -> ModelClass: with handle_validation_as_http_error( - error_msg_template="Invalid parameter/s '{failed}' in request headers", + error_msg_template=user_message( + "Invalid parameter/s '{failed}' in request headers" + ), resource_name=request.rel_url.path, - use_error_v1=use_enveloped_error_v1, ): data = dict(request.headers) return parameters_schema_cls.model_validate(data) @@ -181,8 +160,6 @@ def parse_request_headers_as( async def parse_request_body_as( model_schema_cls: type[ModelOrListOrDictType], request: web.Request, - *, - use_enveloped_error_v1: bool = True, ) -> ModelOrListOrDictType: """Parses and validates request body against schema @@ -197,9 +174,8 @@ async def parse_request_body_as( Validated model of request body """ with handle_validation_as_http_error( - error_msg_template="Invalid field/s '{failed}' in request body", + error_msg_template=user_message("Invalid field/s '{failed}' in request body"), resource_name=request.rel_url.path, - use_error_v1=use_enveloped_error_v1, ): if not request.can_read_body: # requests w/o body e.g. when model-schema is fully optional diff --git a/packages/service-library/tests/aiohttp/test_requests_validation.py b/packages/service-library/tests/aiohttp/test_requests_validation.py index 5cd12a4ee10..a901cc4d874 100644 --- a/packages/service-library/tests/aiohttp/test_requests_validation.py +++ b/packages/service-library/tests/aiohttp/test_requests_validation.py @@ -13,6 +13,7 @@ from common_library.json_serialization import json_dumps from faker import Faker from models_library.rest_base import RequestParameters, StrictRequestParameters +from models_library.rest_error import EnvelopedError from models_library.rest_ordering import ( OrderBy, OrderDirection, @@ -116,18 +117,10 @@ async def _handler(request: web.Request) -> web.Response: {**dict(request.app), **dict(request)} ) - path_params = parse_request_path_parameters_as( - MyRequestPathParams, request, use_enveloped_error_v1=False - ) - query_params = parse_request_query_parameters_as( - MyRequestQueryParams, request, use_enveloped_error_v1=False - ) - headers_params = parse_request_headers_as( - MyRequestHeadersParams, request, use_enveloped_error_v1=False - ) - body = await parse_request_body_as( - MyBody, request, use_enveloped_error_v1=False - ) + path_params = parse_request_path_parameters_as(MyRequestPathParams, request) + query_params = parse_request_query_parameters_as(MyRequestQueryParams, request) + headers_params = parse_request_headers_as(MyRequestHeadersParams, request) + body = await parse_request_body_as(MyBody, request) # --------------------------- return web.json_response( @@ -230,19 +223,12 @@ async def test_parse_request_with_invalid_path_params( assert r.status == status.HTTP_422_UNPROCESSABLE_ENTITY, f"{await r.text()}" response_body = await r.json() - assert response_body["error"].pop("resource") - assert response_body == { - "error": { - "msg": "Invalid parameter/s 'project_uuid' in request path", - "details": [ - { - "loc": "project_uuid", - "msg": "Input should be a valid UUID, invalid character: expected an optional prefix of `urn:uuid:` followed by [0-9a-fA-F-], found `i` at 1", - "type": "uuid_parsing", - } - ], - } - } + + error_model = EnvelopedError.model_validate(response_body).error + assert error_model.message == "Invalid parameter/s 'project_uuid' in request path" + assert error_model.status == status.HTTP_422_UNPROCESSABLE_ENTITY + assert error_model.errors[0].field == "project_uuid" + assert error_model.errors[0].code == "uuid_parsing" async def test_parse_request_with_invalid_query_params( @@ -261,19 +247,11 @@ async def test_parse_request_with_invalid_query_params( assert r.status == status.HTTP_422_UNPROCESSABLE_ENTITY, f"{await r.text()}" response_body = await r.json() - assert response_body["error"].pop("resource") - assert response_body == { - "error": { - "msg": "Invalid parameter/s 'label' in request query", - "details": [ - { - "loc": "label", - "msg": "Field required", - "type": "missing", - } - ], - } - } + error_model = EnvelopedError.model_validate(response_body).error + assert error_model.message == "Invalid parameter/s 'label' in request query" + assert error_model.status == status.HTTP_422_UNPROCESSABLE_ENTITY + assert error_model.errors[0].field == "label" + assert error_model.errors[0].code == "missing" async def test_parse_request_with_invalid_body( @@ -293,25 +271,11 @@ async def test_parse_request_with_invalid_body( response_body = await r.json() - assert response_body["error"].pop("resource") - - assert response_body == { - "error": { - "msg": "Invalid field/s 'x, z' in request body", - "details": [ - { - "loc": "x", - "msg": "Field required", - "type": "missing", - }, - { - "loc": "z", - "msg": "Field required", - "type": "missing", - }, - ], - } - } + error_model = EnvelopedError.model_validate(response_body).error + assert error_model.message == "Invalid field/s 'x, z' in request body" + assert error_model.status == status.HTTP_422_UNPROCESSABLE_ENTITY + assert error_model.errors[0].field == "x" + assert error_model.errors[0].code == "missing" async def test_parse_request_with_invalid_json_body( @@ -349,19 +313,15 @@ async def test_parse_request_with_invalid_headers_params( assert r.status == status.HTTP_422_UNPROCESSABLE_ENTITY, f"{await r.text()}" response_body = await r.json() - assert response_body["error"].pop("resource") - assert response_body == { - "error": { - "msg": "Invalid parameter/s 'X-Simcore-User-Agent' in request headers", - "details": [ - { - "loc": "X-Simcore-User-Agent", - "msg": "Field required", - "type": "missing", - } - ], - } - } + + error_model = EnvelopedError.model_validate(response_body).error + assert ( + error_model.message + == "Invalid parameter/s 'X-Simcore-User-Agent' in request headers" + ) + assert error_model.status == status.HTTP_422_UNPROCESSABLE_ENTITY + assert error_model.errors[0].field == "X-Simcore-User-Agent" + assert error_model.errors[0].code == "missing" def test_parse_request_query_parameters_as_with_order_by_query_models(): diff --git a/services/web/server/VERSION b/services/web/server/VERSION index 7375dee5f49..25f7700ed71 100644 --- a/services/web/server/VERSION +++ b/services/web/server/VERSION @@ -1 +1 @@ -0.72.0 +0.73.1 diff --git a/services/web/server/requirements/_base.in b/services/web/server/requirements/_base.in index 977b148c687..2af5fb69ea3 100644 --- a/services/web/server/requirements/_base.in +++ b/services/web/server/requirements/_base.in @@ -45,6 +45,7 @@ opentelemetry-instrumentation-aiopg orjson # json packaging passlib +phonenumbers pint # units pycountry pydantic[email] # models diff --git a/services/web/server/requirements/_base.txt b/services/web/server/requirements/_base.txt index b2a23208960..8795219d773 100644 --- a/services/web/server/requirements/_base.txt +++ b/services/web/server/requirements/_base.txt @@ -584,6 +584,8 @@ pamqp==3.2.1 # via aiormq passlib==1.7.4 # via -r requirements/_base.in +phonenumbers==9.0.9 + # via -r requirements/_base.in pillow==10.3.0 # via captcha pint==0.24.3 diff --git a/services/web/server/setup.cfg b/services/web/server/setup.cfg index bba1e07bf00..ac0a8feb45f 100644 --- a/services/web/server/setup.cfg +++ b/services/web/server/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.72.0 +current_version = 0.73.1 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 759adedfa02..96b4071f7b3 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.72.0 + version: 0.73.1 servers: - url: '' description: webserver @@ -1560,7 +1560,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/PreRegisteredUserGet' + $ref: '#/components/schemas/UserAccountRestPreRegister' required: true responses: '200': @@ -8574,14 +8574,14 @@ components: application: Antenna_Design city: Washington company: EM Com - country: USA + country: Switzerland description: Description of something email: maxwel@email.com eula: true firstName: James hear: Search_Engine lastName: Maxwel - phone: +1 123456789 + phone: +41 44 245 96 96 postalCode: '98001' privacyPolicy: true AccountRequestStatus: @@ -13113,7 +13113,7 @@ components: properties: phone: type: string - minLength: 1 + format: phone title: Phone description: Phone number to register type: object @@ -14653,62 +14653,6 @@ components: - x - y title: Position - PreRegisteredUserGet: - properties: - firstName: - type: string - title: Firstname - lastName: - type: string - title: Lastname - email: - type: string - format: email - title: Email - institution: - anyOf: - - type: string - - type: 'null' - title: Institution - description: company, university, ... - phone: - anyOf: - - type: string - - type: 'null' - title: Phone - address: - type: string - title: Address - city: - type: string - title: City - state: - anyOf: - - type: string - - type: 'null' - title: State - postalCode: - type: string - title: Postalcode - country: - type: string - title: Country - extras: - additionalProperties: true - type: object - title: Extras - description: Keeps extra information provided in the request form. - type: object - required: - - firstName - - lastName - - email - - phone - - address - - city - - postalCode - - country - title: PreRegisteredUserGet Preference: properties: defaultValue: @@ -15935,6 +15879,7 @@ components: title: Email phone: type: string + format: phone title: Phone description: Phone number E.164, needed on the deployments with 2FA additionalProperties: false @@ -17727,6 +17672,63 @@ components: required: - email title: UserAccountReject + UserAccountRestPreRegister: + properties: + firstName: + type: string + title: Firstname + lastName: + type: string + title: Lastname + email: + type: string + format: email + title: Email + institution: + anyOf: + - type: string + - type: 'null' + title: Institution + description: company, university, ... + phone: + anyOf: + - type: string + format: phone + - type: 'null' + title: Phone + address: + type: string + title: Address + city: + type: string + title: City + state: + anyOf: + - type: string + - type: 'null' + title: State + postalCode: + type: string + title: Postalcode + country: + type: string + title: Country + extras: + additionalProperties: true + type: object + title: Extras + description: Keeps extra information provided in the request form. + type: object + required: + - firstName + - lastName + - email + - phone + - address + - city + - postalCode + - country + title: UserAccountRestPreRegister UserGet: properties: userId: diff --git a/services/web/server/src/simcore_service_webserver/catalog/_controller_rest_schemas.py b/services/web/server/src/simcore_service_webserver/catalog/_controller_rest_schemas.py index 83c8dbe9fa4..a27f71d61d2 100644 --- a/services/web/server/src/simcore_service_webserver/catalog/_controller_rest_schemas.py +++ b/services/web/server/src/simcore_service_webserver/catalog/_controller_rest_schemas.py @@ -160,7 +160,6 @@ def create(cls, request: Request) -> "CatalogRequestContext": with handle_validation_as_http_error( error_msg_template="Invalid request", resource_name=request.rel_url.path, - use_error_v1=True, ): assert request.app # nosec return cls( diff --git a/services/web/server/src/simcore_service_webserver/functions/_controller/_functions_rest.py b/services/web/server/src/simcore_service_webserver/functions/_controller/_functions_rest.py index 9d263d58136..aa80b3cf301 100644 --- a/services/web/server/src/simcore_service_webserver/functions/_controller/_functions_rest.py +++ b/services/web/server/src/simcore_service_webserver/functions/_controller/_functions_rest.py @@ -32,7 +32,6 @@ async def register_function(request: web.Request) -> web.Response: with handle_validation_as_http_error( error_msg_template="Invalid parameter/s '{failed}' in request path", resource_name=request.rel_url.path, - use_error_v1=True, ): function_to_register: FunctionToRegister = TypeAdapter( FunctionToRegister diff --git a/services/web/server/src/simcore_service_webserver/login/_controller/rest/registration_schemas.py b/services/web/server/src/simcore_service_webserver/login/_controller/rest/registration_schemas.py index ac2c4f9fed1..a22b36be172 100644 --- a/services/web/server/src/simcore_service_webserver/login/_controller/rest/registration_schemas.py +++ b/services/web/server/src/simcore_service_webserver/login/_controller/rest/registration_schemas.py @@ -11,6 +11,7 @@ field_validator, ) +from ....models import PhoneNumberStr from ....utils_aiohttp import NextPage from ..._models import InputSchema, check_confirm_password_match @@ -54,7 +55,8 @@ class RegisterBody(InputSchema): class RegisterPhoneBody(InputSchema): email: LowerCaseEmailStr phone: Annotated[ - str, Field(description="Phone number E.164, needed on the deployments with 2FA") + PhoneNumberStr, + Field(description="Phone number E.164, needed on the deployments with 2FA"), ] @@ -63,6 +65,6 @@ class _PageParams(BaseModel): class RegisterPhoneNextPage(NextPage[_PageParams]): - logger: str = Field("user", deprecated=True) + logger: Annotated[str, Field(deprecated=True)] = "user" level: Literal["INFO", "WARNING", "ERROR"] = "INFO" message: str diff --git a/services/web/server/src/simcore_service_webserver/login_accounts/_controller_rest.py b/services/web/server/src/simcore_service_webserver/login_accounts/_controller_rest.py index 4ba2ae25541..c00156ec79f 100644 --- a/services/web/server/src/simcore_service_webserver/login_accounts/_controller_rest.py +++ b/services/web/server/src/simcore_service_webserver/login_accounts/_controller_rest.py @@ -2,13 +2,17 @@ from typing import Any from aiohttp import web +from common_library.user_messages import user_message from models_library.api_schemas_webserver.auth import ( AccountRequestInfo, UnregisterCheck, ) from servicelib.aiohttp import status from servicelib.aiohttp.application_keys import APP_FIRE_AND_FORGET_TASKS_KEY -from servicelib.aiohttp.requests_validation import parse_request_body_as +from servicelib.aiohttp.requests_validation import ( + handle_validation_as_http_error, + parse_request_body_as, +) from servicelib.logging_utils import get_log_record_extra, log_context from servicelib.utils import fire_and_forget_task @@ -29,7 +33,7 @@ from ..security.decorators import permission_required from ..session import api as session_service from ..users import users_service -from ..users.schemas import PreRegisteredUserGet +from ..users.schemas import UserAccountRestPreRegister from ..utils import MINUTE from ..utils_rate_limiting import global_rate_limit_route from ..web_utils import flash_response @@ -88,10 +92,17 @@ async def request_product_account(request: web.Request): raise web.HTTPUnprocessableEntity(text=MSG_WRONG_CAPTCHA__INVALID) session.pop(CAPTCHA_SESSION_KEY, None) - # create pre-regiatration or raise if already exists + with handle_validation_as_http_error( + error_msg_template=user_message( + "The form contains invalid information: '{failed}'", _version=1 + ), + resource_name=request.rel_url.path, + ): + profile = UserAccountRestPreRegister.model_validate(body.form) + await _service.create_pre_registration( request.app, - profile=PreRegisteredUserGet.model_validate(body.form), + profile=profile, product_name=product.name, ) diff --git a/services/web/server/src/simcore_service_webserver/login_accounts/_service.py b/services/web/server/src/simcore_service_webserver/login_accounts/_service.py index 4ce1c8c4f74..5173a861ce2 100644 --- a/services/web/server/src/simcore_service_webserver/login_accounts/_service.py +++ b/services/web/server/src/simcore_service_webserver/login_accounts/_service.py @@ -18,7 +18,7 @@ from ..products import products_web from ..products.models import Product from ..users import _accounts_service -from ..users.schemas import PreRegisteredUserGet +from ..users.schemas import UserAccountRestPreRegister _logger = logging.getLogger(__name__) @@ -133,7 +133,10 @@ def _run() -> tuple[str, bytes]: async def create_pre_registration( - app: web.Application, *, profile: PreRegisteredUserGet, product_name: ProductName + app: web.Application, + *, + profile: UserAccountRestPreRegister, + product_name: ProductName, ): await _accounts_service.pre_register_user( diff --git a/services/web/server/src/simcore_service_webserver/models.py b/services/web/server/src/simcore_service_webserver/models.py index 4cd06a1abb1..14975acc96a 100644 --- a/services/web/server/src/simcore_service_webserver/models.py +++ b/services/web/server/src/simcore_service_webserver/models.py @@ -1,13 +1,20 @@ -from typing import Annotated +from typing import Annotated, TypeAlias from models_library.products import ProductName from models_library.rest_base import RequestParameters from models_library.users import UserID from pydantic import ConfigDict, Field +from pydantic_extra_types.phone_numbers import PhoneNumberValidator from servicelib.request_keys import RQT_USERID_KEY from .constants import RQ_PRODUCT_KEY +PhoneNumberStr: TypeAlias = Annotated[ + # NOTE: validator require installing `phonenumbers`` + str, + PhoneNumberValidator(number_format="E164"), +] + class AuthenticatedRequestContext(RequestParameters): """Fields expected in the request context for authenticated endpoints""" diff --git a/services/web/server/src/simcore_service_webserver/users/_accounts_service.py b/services/web/server/src/simcore_service_webserver/users/_accounts_service.py index 23f06872e58..bd189ad1c27 100644 --- a/services/web/server/src/simcore_service_webserver/users/_accounts_service.py +++ b/services/web/server/src/simcore_service_webserver/users/_accounts_service.py @@ -18,7 +18,7 @@ AlreadyPreRegisteredError, PendingPreRegistrationNotFoundError, ) -from .schemas import PreRegisteredUserGet +from .schemas import UserAccountRestPreRegister _logger = logging.getLogger(__name__) @@ -30,7 +30,7 @@ async def pre_register_user( app: web.Application, *, - profile: PreRegisteredUserGet, + profile: UserAccountRestPreRegister, creator_user_id: UserID | None, product_name: ProductName, ) -> UserAccountGet: diff --git a/services/web/server/src/simcore_service_webserver/users/_controller/rest/_rest_schemas.py b/services/web/server/src/simcore_service_webserver/users/_controller/rest/_rest_schemas.py index cb5ebcee15f..df300668cac 100644 --- a/services/web/server/src/simcore_service_webserver/users/_controller/rest/_rest_schemas.py +++ b/services/web/server/src/simcore_service_webserver/users/_controller/rest/_rest_schemas.py @@ -10,38 +10,66 @@ from typing import Annotated, Any, Final import pycountry +from common_library.basic_types import DEFAULT_FACTORY from models_library.api_schemas_webserver._base import InputSchema from models_library.api_schemas_webserver.users import UserAccountGet from models_library.emails import LowerCaseEmailStr -from models_library.users import UserID -from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator -from servicelib.request_keys import RQT_USERID_KEY +from pydantic import ( + ConfigDict, + Field, + StringConstraints, + field_validator, + model_validator, +) -from ....constants import RQ_PRODUCT_KEY +from ....models import AuthenticatedRequestContext, PhoneNumberStr +MAX_BYTES_SIZE_EXTRAS: Final[int] = 512 -class UsersRequestContext(BaseModel): - user_id: UserID = Field(..., alias=RQT_USERID_KEY) # type: ignore[literal-required] - product_name: str = Field(..., alias=RQ_PRODUCT_KEY) # type: ignore[literal-required] +class UsersRequestContext(AuthenticatedRequestContext): ... -MAX_BYTES_SIZE_EXTRAS: Final[int] = 512 +# +# PHONE REGISTRATION +# -class PreRegisteredUserGet(InputSchema): - # NOTE: validators need pycountry! + +class MyPhoneRegister(InputSchema): + phone: Annotated[ + PhoneNumberStr, + 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-ACCCOUNT +# + + +class UserAccountRestPreRegister(InputSchema): + # NOTE: validators require installing `pycountry` first_name: str last_name: str email: LowerCaseEmailStr - institution: str | None = Field( - default=None, description="company, university, ..." - ) - phone: str | None + institution: Annotated[ + str | None, Field(description="company, university, ...") + ] = None + phone: PhoneNumberStr | None + # billing details address: str city: str - state: str | None = Field(default=None) + state: str | None = None postal_code: str country: str extras: Annotated[ @@ -50,7 +78,7 @@ class PreRegisteredUserGet(InputSchema): default_factory=dict, description="Keeps extra information provided in the request form.", ), - ] + ] = DEFAULT_FACTORY model_config = ConfigDict(str_strip_whitespace=True, str_max_length=200) @@ -107,7 +135,7 @@ def _pre_check_and_normalize_country(cls, v): return v -# asserts field names are in sync -assert set(PreRegisteredUserGet.model_fields).issubset( +assert set(UserAccountRestPreRegister.model_fields).issubset( # nosec + # asserts field names are in sync UserAccountGet.model_fields -) # nosec +) diff --git a/services/web/server/src/simcore_service_webserver/users/_controller/rest/accounts_rest.py b/services/web/server/src/simcore_service_webserver/users/_controller/rest/accounts_rest.py index 680c0c12e41..d8b060adb89 100644 --- a/services/web/server/src/simcore_service_webserver/users/_controller/rest/accounts_rest.py +++ b/services/web/server/src/simcore_service_webserver/users/_controller/rest/accounts_rest.py @@ -30,7 +30,7 @@ from ....utils_aiohttp import create_json_response_from_page, envelope_json_response from ... import _accounts_service from ._rest_exceptions import handle_rest_requests_exceptions -from ._rest_schemas import PreRegisteredUserGet, UsersRequestContext +from ._rest_schemas import UserAccountRestPreRegister, UsersRequestContext _logger = logging.getLogger(__name__) @@ -125,7 +125,7 @@ async def search_user_accounts(request: web.Request) -> web.Response: @handle_rest_requests_exceptions async def pre_register_user_account(request: web.Request) -> web.Response: req_ctx = UsersRequestContext.model_validate(request) - pre_user_profile = await parse_request_body_as(PreRegisteredUserGet, request) + pre_user_profile = await parse_request_body_as(UserAccountRestPreRegister, request) user_profile = await _accounts_service.pre_register_user( request.app, 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 88a2a03dce9..c242fb5e1eb 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,6 @@ from aiohttp import web from models_library.api_schemas_webserver.users import ( - MyPhoneConfirm, - MyPhoneRegister, MyProfileRestGet, MyProfileRestPatch, UserGet, @@ -30,7 +28,7 @@ from ... import _users_service from ..._users_web import RegistrationSessionManager from ._rest_exceptions import handle_rest_requests_exceptions -from ._rest_schemas import UsersRequestContext +from ._rest_schemas import MyPhoneConfirm, MyPhoneRegister, UsersRequestContext _logger = logging.getLogger(__name__) diff --git a/services/web/server/src/simcore_service_webserver/users/schemas.py b/services/web/server/src/simcore_service_webserver/users/schemas.py index 7c9d7997abd..e4520d25311 100644 --- a/services/web/server/src/simcore_service_webserver/users/schemas.py +++ b/services/web/server/src/simcore_service_webserver/users/schemas.py @@ -1,6 +1,9 @@ -from ._controller.rest._rest_schemas import PreRegisteredUserGet, UsersRequestContext +from ._controller.rest._rest_schemas import ( + UserAccountRestPreRegister, + UsersRequestContext, +) -__all__: tuple[str, ...] = ("PreRegisteredUserGet", "UsersRequestContext") +__all__: tuple[str, ...] = ("UserAccountRestPreRegister", "UsersRequestContext") # nopycln: file diff --git a/services/web/server/tests/conftest.py b/services/web/server/tests/conftest.py index b6c62eae17b..976d400f1ad 100644 --- a/services/web/server/tests/conftest.py +++ b/services/web/server/tests/conftest.py @@ -6,7 +6,6 @@ import contextlib import json import logging -import random import sys from collections.abc import AsyncIterator, Awaitable, Callable from copy import deepcopy @@ -23,8 +22,10 @@ from models_library.projects import ProjectID from models_library.projects_nodes_io import NodeID from models_library.projects_state import ProjectState +from pydantic import TypeAdapter from pytest_mock import MockerFixture from pytest_simcore.helpers.assert_checks import assert_status +from pytest_simcore.helpers.faker_factories import random_phone_number from pytest_simcore.helpers.monkeypatch_envs import EnvVarsDict, setenvs_from_dict from pytest_simcore.helpers.webserver_login import LoggedUser from pytest_simcore.helpers.webserver_users import NewUser, UserInfoDict @@ -40,6 +41,7 @@ convert_to_environ_vars, ) from simcore_service_webserver.db.models import UserRole +from simcore_service_webserver.models import PhoneNumberStr from simcore_service_webserver.projects._crud_api_create import ( OVERRIDABLE_DOCUMENT_KEYS, ) @@ -143,6 +145,11 @@ def fake_project(tests_data_dir: Path) -> ProjectDict: return json.loads(fpath.read_text()) +@pytest.fixture +def user_phone_number(faker: Faker) -> PhoneNumberStr: + return TypeAdapter(PhoneNumberStr).validate_python(random_phone_number(faker)) + + @pytest.fixture async def user(client: TestClient) -> AsyncIterator[UserInfoDict]: async with NewUser( @@ -156,7 +163,10 @@ async def user(client: TestClient) -> AsyncIterator[UserInfoDict]: @pytest.fixture async def logged_user( - client: TestClient, user_role: UserRole, faker: Faker + client: TestClient, + user_role: UserRole, + faker: Faker, + user_phone_number: PhoneNumberStr, ) -> AsyncIterator[UserInfoDict]: """adds a user in db and logs in with client @@ -168,8 +178,7 @@ async def logged_user( "role": user_role.name, "first_name": faker.first_name(), "last_name": faker.last_name(), - "phone": faker.phone_number() - + f"{random.randint(1000, 9999)}", # noqa: S311 + "phone": user_phone_number, }, check_if_succeeds=user_role != UserRole.ANONYMOUS, ) as user: diff --git a/services/web/server/tests/unit/isolated/test_models.py b/services/web/server/tests/unit/isolated/test_models.py new file mode 100644 index 00000000000..04e22afc684 --- /dev/null +++ b/services/web/server/tests/unit/isolated/test_models.py @@ -0,0 +1,69 @@ +# pylint: disable=protected-access +# pylint: disable=redefined-outer-name +# pylint: disable=too-many-arguments +# pylint: disable=unused-argument +# pylint: disable=unused-variable + + +import pytest +from faker import Faker +from pydantic import TypeAdapter, ValidationError +from pytest_simcore.helpers.faker_factories import random_phone_number +from simcore_service_webserver.users._controller.rest._rest_schemas import ( + MyPhoneRegister, + PhoneNumberStr, +) + + +@pytest.mark.parametrize( + "phone", + [ + "+41763456789", + "+19104630364", + "+1 301-304-4567", + "+41763456686", + "+19104630873", + "+19104630424", + "+34 950 453 772", + "+19104630700", + "+13013044719", + ], +) +def test_valid_phone_numbers(phone: str): + # This test is used to tune options of PhoneNumberValidator + assert MyPhoneRegister.model_validate({"phone": phone}).phone == TypeAdapter( + PhoneNumberStr + ).validate_python(phone) + + +def test_random_phone_number(): + # This test is used to tune options of PhoneNumberValidator + for _ in range(10): + phone = random_phone_number(Faker(seed=42)) + assert MyPhoneRegister.model_validate({"phone": phone}).phone == TypeAdapter( + PhoneNumberStr + ).validate_python(phone) + + +@pytest.mark.parametrize( + "phone", + [ + "+41763456789", + "+41 76 345 67 89", + "tel:+41-76-345-67-89", + ], + ids=["E.164", "INTERNATIONAL", "RFC3966"], +) +def test_autoformat_phone_number_to_e164(phone: str): + # This test is used to tune options of PhoneNumberValidator formatting to E164 + assert TypeAdapter(PhoneNumberStr).validate_python(phone) == "+41763456789" + + +@pytest.mark.parametrize( + "phone", + ["41763456789", "+09104630364", "+1 111-304-4567"], +) +def test_invalid_phone_numbers(phone: str): + # This test is used to tune options of PhoneNumberValidator + with pytest.raises(ValidationError): + MyPhoneRegister.model_validate({"phone": phone}) diff --git a/services/web/server/tests/unit/with_dbs/03/invitations/test_products_rest_invitations.py b/services/web/server/tests/unit/with_dbs/03/invitations/test_products_rest_invitations.py index cd9159feec5..dbc4f39b555 100644 --- a/services/web/server/tests/unit/with_dbs/03/invitations/test_products_rest_invitations.py +++ b/services/web/server/tests/unit/with_dbs/03/invitations/test_products_rest_invitations.py @@ -23,6 +23,7 @@ from pytest_simcore.helpers.webserver_users import UserInfoDict from servicelib.aiohttp import status from simcore_postgres_database.models.users import UserRole +from simcore_service_webserver.models import PhoneNumberStr @pytest.mark.parametrize( @@ -131,13 +132,14 @@ async def test_pre_registration_and_invitation_workflow( expected_status: HTTPStatus, guest_email: str, faker: Faker, + user_phone_number: PhoneNumberStr, ): requester_info = { "firstName": faker.first_name(), "lastName": faker.last_name(), "email": guest_email, "companyName": faker.company(), - "phone": faker.phone_number(), + "phone": user_phone_number, # billing info "address": faker.address().replace("\n", ", "), "city": faker.city(), diff --git a/services/web/server/tests/unit/with_dbs/03/invitations/test_users_accounts_rest_registration.py b/services/web/server/tests/unit/with_dbs/03/invitations/test_users_accounts_rest_registration.py index a56c209de05..c1964c4a46c 100644 --- a/services/web/server/tests/unit/with_dbs/03/invitations/test_users_accounts_rest_registration.py +++ b/services/web/server/tests/unit/with_dbs/03/invitations/test_users_accounts_rest_registration.py @@ -42,6 +42,7 @@ users_pre_registration_details, ) from simcore_service_webserver.db.plugin import get_asyncpg_engine +from simcore_service_webserver.models import PhoneNumberStr @pytest.fixture @@ -115,13 +116,16 @@ async def test_access_rights_on_search_users_only_product_owners_can_access( @pytest.fixture -def account_request_form(faker: Faker) -> dict[str, Any]: +def account_request_form( + faker: Faker, + user_phone_number: PhoneNumberStr, +) -> dict[str, Any]: # This is AccountRequestInfo.form form = { "firstName": faker.first_name(), "lastName": faker.last_name(), "email": faker.email(), - "phone": faker.phone_number(), + "phone": user_phone_number, "company": faker.company(), # billing info "address": faker.address().replace("\n", ", "), diff --git a/services/web/server/tests/unit/with_dbs/03/login/conftest.py b/services/web/server/tests/unit/with_dbs/03/login/conftest.py index 0da20431a59..a6b1dd9d596 100644 --- a/services/web/server/tests/unit/with_dbs/03/login/conftest.py +++ b/services/web/server/tests/unit/with_dbs/03/login/conftest.py @@ -76,11 +76,6 @@ def app_environment( return {**app_environment, **envs_plugins, **envs_login, **envs_twilio} -@pytest.fixture -def user_phone_number(faker: Faker) -> str: - return faker.phone_number() - - @pytest.fixture def fake_weak_password(faker: Faker) -> str: return faker.password( diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_change_password.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_change_password.py index e433635e604..2653591ea90 100644 --- a/services/web/server/tests/unit/with_dbs/03/login/test_login_change_password.py +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_change_password.py @@ -90,6 +90,7 @@ async def test_wrong_confirm_pass(client: TestClient, new_password: str): "field": "confirm", } ], + "message": "Invalid field/s 'confirm' in request body", } 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 ea7bbae817e..31dd881fa30 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 @@ -107,6 +107,7 @@ async def test_register_body_validation( "field": "confirm", }, ], + "message": "Invalid field/s 'email, confirm' in request body", } diff --git a/services/web/server/tests/unit/with_dbs/03/test_user_notifications.py b/services/web/server/tests/unit/with_dbs/03/test_user_notifications_rest.py similarity index 100% rename from services/web/server/tests/unit/with_dbs/03/test_user_notifications.py rename to services/web/server/tests/unit/with_dbs/03/test_user_notifications_rest.py diff --git a/services/web/server/tests/unit/with_dbs/03/test_users_rest_models.py b/services/web/server/tests/unit/with_dbs/03/test_users_rest_models.py index 752ce41c21a..8b797d25bd4 100644 --- a/services/web/server/tests/unit/with_dbs/03/test_users_rest_models.py +++ b/services/web/server/tests/unit/with_dbs/03/test_users_rest_models.py @@ -14,20 +14,23 @@ from faker import Faker from models_library.api_schemas_webserver.auth import AccountRequestInfo from pytest_simcore.helpers.faker_factories import random_pre_registration_details +from simcore_service_webserver.models import PhoneNumberStr from simcore_service_webserver.users._controller.rest._rest_schemas import ( MAX_BYTES_SIZE_EXTRAS, - PreRegisteredUserGet, + UserAccountRestPreRegister, ) @pytest.fixture -def account_request_form(faker: Faker) -> dict[str, Any]: +def account_request_form( + faker: Faker, user_phone_number: PhoneNumberStr +) -> dict[str, Any]: # This is AccountRequestInfo.form form = { "firstName": faker.first_name(), "lastName": faker.last_name(), "email": faker.email(), - "phone": faker.phone_number(), + "phone": user_phone_number, "company": faker.company(), # billing info "address": faker.address().replace("\n", ", "), @@ -66,7 +69,7 @@ def test_preuserprofile_parse_model_from_request_form_data( data["comment"] = "extra comment" # pre-processors - pre_user_profile = PreRegisteredUserGet(**data) + pre_user_profile = UserAccountRestPreRegister(**data) print(pre_user_profile.model_dump_json(indent=1)) @@ -90,11 +93,11 @@ def test_preuserprofile_parse_model_without_extras( ): required = { f.alias or f_name - for f_name, f in PreRegisteredUserGet.model_fields.items() + for f_name, f in UserAccountRestPreRegister.model_fields.items() if f.is_required() } data = {k: account_request_form[k] for k in required} - assert not PreRegisteredUserGet(**data).extras + assert not UserAccountRestPreRegister(**data).extras def test_preuserprofile_max_bytes_size_extras_limits(faker: Faker): @@ -114,7 +117,7 @@ def test_preuserprofile_pre_given_names( account_request_form["firstName"] = given_name account_request_form["lastName"] = given_name - pre_user_profile = PreRegisteredUserGet(**account_request_form) + pre_user_profile = UserAccountRestPreRegister(**account_request_form) print(pre_user_profile.model_dump_json(indent=1)) assert pre_user_profile.first_name in ["Pedro-Luis", "Pedro Luis"] assert pre_user_profile.first_name == pre_user_profile.last_name 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 3123f9f98e1..ab7c0fb6f3a 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,7 +17,6 @@ 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 ( MyProfileRestGet, @@ -33,6 +32,7 @@ from pytest_simcore.helpers.webserver_users import NewUser, UserInfoDict from servicelib.aiohttp import status from servicelib.rest_constants import RESPONSE_MODEL_POLICY +from simcore_service_webserver.models import PhoneNumberStr from simcore_service_webserver.user_preferences._service import ( get_frontend_user_preferences_aggregation, ) @@ -602,7 +602,6 @@ async def test_phone_registration_basic_workflow( user_role: UserRole, logged_user: UserInfoDict, client: TestClient, - faker: Faker, ): assert client.app @@ -613,9 +612,11 @@ async def test_phone_registration_basic_workflow( initial_profile = MyProfileRestGet.model_validate(data) initial_phone = initial_profile.phone + assert initial_phone # REGISTER phone number - new_phone = faker.phone_number() + # Change the last 3 digits of the initial phone number to '999' + new_phone = f"{initial_phone[:-3]}999" url = client.app.router["my_phone_register"].url_for() resp = await client.post( f"{url}", @@ -658,7 +659,6 @@ async def test_phone_registration_workflow( user_role: UserRole, logged_user: UserInfoDict, client: TestClient, - faker: Faker, ): assert client.app @@ -669,9 +669,10 @@ async def test_phone_registration_workflow( initial_profile = MyProfileRestGet.model_validate(data) initial_phone = initial_profile.phone + assert initial_phone # STEP 1: REGISTER phone number - new_phone = faker.phone_number() + new_phone = f"{initial_phone[:-3]}999" # Change the last 3 digits to '999' url = client.app.router["my_phone_register"].url_for() resp = await client.post( f"{url}", @@ -714,12 +715,12 @@ async def test_phone_registration_with_resend( user_role: UserRole, logged_user: UserInfoDict, client: TestClient, - faker: Faker, + user_phone_number: PhoneNumberStr, ): assert client.app # STEP 1: REGISTER phone number - new_phone = faker.phone_number() + new_phone = user_phone_number url = client.app.router["my_phone_register"].url_for() resp = await client.post( f"{url}", @@ -760,12 +761,12 @@ async def test_phone_registration_change_existing_phone( user_role: UserRole, logged_user: UserInfoDict, client: TestClient, - faker: Faker, + user_phone_number: PhoneNumberStr, ): assert client.app # Set initial phone - first_phone = faker.phone_number() + first_phone = user_phone_number url = client.app.router["my_phone_register"].url_for() resp = await client.post( f"{url}", @@ -785,7 +786,8 @@ async def test_phone_registration_change_existing_phone( await assert_status(resp, status.HTTP_204_NO_CONTENT) # Change to new phone - new_phone = faker.phone_number() + # Create a different phone number by changing the last digits + new_phone = user_phone_number[:-4] + "9999" url = client.app.router["my_phone_register"].url_for() resp = await client.post( f"{url}", @@ -859,12 +861,12 @@ async def test_phone_confirm_with_wrong_code( user_role: UserRole, logged_user: UserInfoDict, client: TestClient, - faker: Faker, + user_phone_number: PhoneNumberStr, ): assert client.app # STEP 1: REGISTER phone number - new_phone = faker.phone_number() + new_phone = user_phone_number url = client.app.router["my_phone_register"].url_for() resp = await client.post( f"{url}", @@ -890,12 +892,12 @@ async def test_phone_confirm_with_invalid_code_format( user_role: UserRole, logged_user: UserInfoDict, client: TestClient, - faker: Faker, + user_phone_number: PhoneNumberStr, ): assert client.app # STEP 1: REGISTER phone number - new_phone = faker.phone_number() + new_phone = user_phone_number url = client.app.router["my_phone_register"].url_for() resp = await client.post( f"{url}", @@ -950,12 +952,12 @@ async def test_phone_confirm_with_empty_code( user_role: UserRole, logged_user: UserInfoDict, client: TestClient, - faker: Faker, + user_phone_number: PhoneNumberStr, ): assert client.app # STEP 1: REGISTER phone number - new_phone = faker.phone_number() + new_phone = user_phone_number url = client.app.router["my_phone_register"].url_for() resp = await client.post( f"{url}", @@ -988,7 +990,7 @@ async def test_phone_register_access_rights( logged_user: UserInfoDict, client: TestClient, expected: HTTPStatus, - faker: Faker, + user_phone_number: PhoneNumberStr, ): assert client.app @@ -997,7 +999,7 @@ async def test_phone_register_access_rights( resp = await client.post( f"{url}", json={ - "phone": faker.phone_number(), + "phone": user_phone_number, }, ) await assert_status(resp, expected) diff --git a/services/web/server/tests/unit/with_dbs/04/wallets/payments/conftest.py b/services/web/server/tests/unit/with_dbs/04/wallets/payments/conftest.py index dbba74da7a4..2981b89ff15 100644 --- a/services/web/server/tests/unit/with_dbs/04/wallets/payments/conftest.py +++ b/services/web/server/tests/unit/with_dbs/04/wallets/payments/conftest.py @@ -39,6 +39,7 @@ users_pre_registration_details, ) from simcore_service_webserver.db.models import UserRole +from simcore_service_webserver.models import PhoneNumberStr from simcore_service_webserver.payments._methods_api import ( _fake_cancel_creation_of_wallet_payment_method, _fake_delete_wallet_payment_method, @@ -320,7 +321,10 @@ async def _get_invoice_url( @pytest.fixture def setup_user_pre_registration_details_db( - postgres_db: sa.engine.Engine, logged_user: UserInfoDict, faker: Faker + postgres_db: sa.engine.Engine, + logged_user: UserInfoDict, + faker: Faker, + user_phone_number: PhoneNumberStr, ) -> Iterator[int]: with postgres_db.connect() as con: result = con.execute( @@ -330,7 +334,7 @@ def setup_user_pre_registration_details_db( pre_email=faker.email(), pre_first_name=faker.first_name(), pre_last_name=faker.last_name(), - pre_phone=faker.phone_number(), + pre_phone=user_phone_number, institution=faker.company(), address=faker.address().replace("\n", ", "), city=faker.city(),