diff --git a/api/specs/web-server/_users_admin.py b/api/specs/web-server/_users_admin.py index db25af3e5d7..3bec7092c02 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._common.schemas import PreRegisteredUserGet +from simcore_service_webserver.users.schemas import PreRegisteredUserGet router = APIRouter(prefix=f"/{API_VTAG}", tags=["users"]) 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 060441b1b43..d2971ae68a6 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 @@ -300,8 +300,9 @@ class UserAccountGet(OutputSchema): ), ] = DEFAULT_FACTORY - # pre-registration + # pre-registration NOTE: that some users have no pre-registartion and therefore all options here can be none pre_registration_id: int | None + pre_registration_created: datetime | None invited_by: str | None = None account_request_status: AccountRequestStatus | None account_request_reviewed_by: UserID | None = None diff --git a/services/web/server/VERSION b/services/web/server/VERSION index a868f07b12a..534b316aef6 100644 --- a/services/web/server/VERSION +++ b/services/web/server/VERSION @@ -1 +1 @@ -0.69.1 +0.70.0 diff --git a/services/web/server/setup.cfg b/services/web/server/setup.cfg index 8c57c75f43c..7b9d4da1311 100644 --- a/services/web/server/setup.cfg +++ b/services/web/server/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.69.1 +current_version = 0.70.0 commit = True message = services/webserver api version: {current_version} → {new_version} tag = False 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 dec98e42082..a28fa1d7276 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.69.1 + version: 0.70.0 servers: - url: '' description: webserver @@ -17166,6 +17166,12 @@ components: - type: integer - type: 'null' title: Preregistrationid + preRegistrationCreated: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Preregistrationcreated invitedBy: anyOf: - type: string @@ -17217,6 +17223,7 @@ components: - postalCode - country - preRegistrationId + - preRegistrationCreated - accountRequestStatus - registered - status diff --git a/services/web/server/src/simcore_service_webserver/constants.py b/services/web/server/src/simcore_service_webserver/constants.py index 2962f5a3b0d..dbf03900a06 100644 --- a/services/web/server/src/simcore_service_webserver/constants.py +++ b/services/web/server/src/simcore_service_webserver/constants.py @@ -1,6 +1,5 @@ # pylint:disable=unused-import -from sys import version from typing import Final from common_library.user_messages import user_message diff --git a/services/web/server/src/simcore_service_webserver/login/_controller/rest/_rest_exceptions.py b/services/web/server/src/simcore_service_webserver/login/_controller/rest/_rest_exceptions.py new file mode 100644 index 00000000000..162acab01a0 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/login/_controller/rest/_rest_exceptions.py @@ -0,0 +1,35 @@ +from common_library.user_messages import user_message +from servicelib.aiohttp import status + +from ....exception_handling import ( + ExceptionToHttpErrorMap, + HttpErrorInfo, + exception_handling_decorator, + to_exceptions_handlers_map, +) +from ....users.exceptions import AlreadyPreRegisteredError +from ..._constants import MSG_2FA_UNAVAILABLE +from ...errors import SendingVerificationEmailError, SendingVerificationSmsError + +_TO_HTTP_ERROR_MAP: ExceptionToHttpErrorMap = { + AlreadyPreRegisteredError: HttpErrorInfo( + status.HTTP_409_CONFLICT, + user_message( + "An account for the email {email} has been submitted. If you haven't received any updates, please contact support.", + _version=1, + ), + ), + SendingVerificationSmsError: HttpErrorInfo( + status.HTTP_503_SERVICE_UNAVAILABLE, + MSG_2FA_UNAVAILABLE, + ), + SendingVerificationEmailError: HttpErrorInfo( + status.HTTP_503_SERVICE_UNAVAILABLE, + MSG_2FA_UNAVAILABLE, + ), +} + + +handle_rest_requests_exceptions = exception_handling_decorator( + to_exceptions_handlers_map(_TO_HTTP_ERROR_MAP) +) diff --git a/services/web/server/src/simcore_service_webserver/login/_controller/rest/auth.py b/services/web/server/src/simcore_service_webserver/login/_controller/rest/auth.py index ebb8fed1cb9..b84b416bb85 100644 --- a/services/web/server/src/simcore_service_webserver/login/_controller/rest/auth.py +++ b/services/web/server/src/simcore_service_webserver/login/_controller/rest/auth.py @@ -39,8 +39,8 @@ ) from ..._models import InputSchema from ...decorators import login_required -from ...errors import handle_login_exceptions from ...settings import LoginSettingsForProduct, get_plugin_settings +from ._rest_exceptions import handle_rest_requests_exceptions log = logging.getLogger(__name__) @@ -75,7 +75,7 @@ class LoginNextPage(NextPage[CodePageParams]): ... name="auth_resend_2fa_code", max_access_count=MAX_2FA_CODE_RESEND, ) -@handle_login_exceptions +@handle_rest_requests_exceptions async def login(request: web.Request): """Login: user submits an email (identification) and a password @@ -218,6 +218,7 @@ class LoginTwoFactorAuthBody(InputSchema): "auth_login_2fa", unauthorized_reason=MSG_UNAUTHORIZED_LOGIN_2FA, ) +@handle_rest_requests_exceptions async def login_2fa(request: web.Request): """Login (continuation): Submits 2FA code""" product: Product = products_web.get_current_product(request) @@ -266,6 +267,7 @@ class LogoutBody(InputSchema): @routes.post(f"/{API_VTAG}/auth/logout", name="auth_logout") @login_required +@handle_rest_requests_exceptions async def logout(request: web.Request) -> web.Response: user_id = request.get(RQT_USERID_KEY, -1) diff --git a/services/web/server/src/simcore_service_webserver/login/_controller/rest/change.py b/services/web/server/src/simcore_service_webserver/login/_controller/rest/change.py index 7f695e44ed7..6f0a6992338 100644 --- a/services/web/server/src/simcore_service_webserver/login/_controller/rest/change.py +++ b/services/web/server/src/simcore_service_webserver/login/_controller/rest/change.py @@ -6,7 +6,6 @@ from pydantic import SecretStr, field_validator from servicelib.aiohttp.requests_validation import parse_request_body_as from servicelib.logging_errors import create_troubleshootting_log_kwargs -from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON from servicelib.request_keys import RQT_USERID_KEY from simcore_postgres_database.utils_repos import pass_or_acquire_connection from simcore_postgres_database.utils_users import UsersRepo @@ -296,9 +295,7 @@ async def change_password(request: web.Request): if not security_service.check_password( passwords.current.get_secret_value(), user["password_hash"] ): - raise web.HTTPUnprocessableEntity( - text=MSG_WRONG_PASSWORD, content_type=MIMETYPE_APPLICATION_JSON - ) # 422 + raise web.HTTPUnprocessableEntity(text=MSG_WRONG_PASSWORD) # 422 await db.update_user( dict(user), diff --git a/services/web/server/src/simcore_service_webserver/login/_controller/rest/preregistration.py b/services/web/server/src/simcore_service_webserver/login/_controller/rest/preregistration.py index d74a1bf00e1..bad370c31a0 100644 --- a/services/web/server/src/simcore_service_webserver/login/_controller/rest/preregistration.py +++ b/services/web/server/src/simcore_service_webserver/login/_controller/rest/preregistration.py @@ -12,7 +12,6 @@ from servicelib.aiohttp.application_keys import APP_FIRE_AND_FORGET_TASKS_KEY from servicelib.aiohttp.requests_validation import parse_request_body_as from servicelib.logging_utils import get_log_record_extra, log_context -from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON from servicelib.request_keys import RQT_USERID_KEY from servicelib.utils import fire_and_forget_task @@ -22,8 +21,9 @@ from ....products.models import Product from ....security import security_service, security_web from ....security.decorators import permission_required -from ....session.api import get_session -from ....users.api import get_user_credentials, set_user_as_deleted +from ....session import api as session_service +from ....users import api as users_service +from ....users._common.schemas import PreRegisteredUserGet from ....utils import MINUTE from ....utils_rate_limiting import global_rate_limit_route from ... import _preregistration_service @@ -35,6 +35,7 @@ from ..._login_service import flash_response, notify_user_logout from ...decorators import login_required from ...settings import LoginSettingsForProduct, get_plugin_settings +from ._rest_exceptions import handle_rest_requests_exceptions _logger = logging.getLogger(__name__) @@ -62,24 +63,30 @@ def _get_ipinfo(request: web.Request) -> dict[str, Any]: name="request_product_account", ) @global_rate_limit_route(number_of_requests=30, interval_seconds=MINUTE) +@handle_rest_requests_exceptions async def request_product_account(request: web.Request): product = products_web.get_current_product(request) - session = await get_session(request) + session = await session_service.get_session(request) body = await parse_request_body_as(AccountRequestInfo, request) assert body.form # nosec assert body.captcha # nosec if body.captcha != session.get(CAPTCHA_SESSION_KEY): - raise web.HTTPUnprocessableEntity( - text=MSG_WRONG_CAPTCHA__INVALID, content_type=MIMETYPE_APPLICATION_JSON - ) + raise web.HTTPUnprocessableEntity(text=MSG_WRONG_CAPTCHA__INVALID) session.pop(CAPTCHA_SESSION_KEY, None) - # send email to fogbugz or user itself + # create pre-regiatration or raise if already exists + await _preregistration_service.create_pre_registration( + request.app, + profile=PreRegisteredUserGet.model_validate(body.form), + product_name=product.name, + ) + + # if created send email to fogbugz or user itself fire_and_forget_task( _preregistration_service.send_account_request_email_to_support( - request, + request=request, product=product, request_form=body.form, ipinfo=_get_ipinfo(request), @@ -98,6 +105,7 @@ class _AuthenticatedContext(BaseModel): @routes.post(f"/{API_VTAG}/auth/unregister", name="unregister_account") @login_required @permission_required("user.profile.delete") +@handle_rest_requests_exceptions async def unregister_account(request: web.Request): req_ctx = _AuthenticatedContext.model_validate(request) body = await parse_request_body_as(UnregisterCheck, request) @@ -108,7 +116,9 @@ async def unregister_account(request: web.Request): ) # checks before deleting - credentials = await get_user_credentials(request.app, user_id=req_ctx.user_id) + credentials = await users_service.get_user_credentials( + request.app, user_id=req_ctx.user_id + ) if body.email != credentials.email.lower() or not security_service.check_password( body.password.get_secret_value(), credentials.password_hash ): @@ -124,7 +134,7 @@ async def unregister_account(request: web.Request): extra=get_log_record_extra(user_id=req_ctx.user_id), ): # update user table - await set_user_as_deleted(request.app, user_id=req_ctx.user_id) + await users_service.set_user_as_deleted(request.app, user_id=req_ctx.user_id) # logout await notify_user_logout( @@ -150,8 +160,9 @@ async def unregister_account(request: web.Request): @routes.get(f"/{API_VTAG}/auth/captcha", name="create_captcha") @global_rate_limit_route(number_of_requests=30, interval_seconds=MINUTE) +@handle_rest_requests_exceptions async def create_captcha(request: web.Request): - session = await get_session(request) + session = await session_service.get_session(request) captcha_text, image_data = await _preregistration_service.create_captcha() diff --git a/services/web/server/src/simcore_service_webserver/login/_controller/rest/twofa.py b/services/web/server/src/simcore_service_webserver/login/_controller/rest/twofa.py index f95134bfb06..a0f7020d961 100644 --- a/services/web/server/src/simcore_service_webserver/login/_controller/rest/twofa.py +++ b/services/web/server/src/simcore_service_webserver/login/_controller/rest/twofa.py @@ -23,8 +23,8 @@ from ..._login_repository_legacy import AsyncpgStorage, get_plugin_storage from ..._login_service import envelope_response from ..._models import InputSchema -from ...errors import handle_login_exceptions from ...settings import LoginSettingsForProduct, get_plugin_settings +from ._rest_exceptions import handle_rest_requests_exceptions _logger = logging.getLogger(__name__) @@ -42,7 +42,7 @@ class Resend2faBody(InputSchema): name="auth_resend_2fa_code", one_time_access=False, ) -@handle_login_exceptions +@handle_rest_requests_exceptions async def resend_2fa_code(request: web.Request): """Resends 2FA code via SMS/Email""" product: Product = products_web.get_current_product(request) diff --git a/services/web/server/src/simcore_service_webserver/login/_preregistration_service.py b/services/web/server/src/simcore_service_webserver/login/_preregistration_service.py index e5e8a9f29ea..0c615991daf 100644 --- a/services/web/server/src/simcore_service_webserver/login/_preregistration_service.py +++ b/services/web/server/src/simcore_service_webserver/login/_preregistration_service.py @@ -7,6 +7,7 @@ from captcha.image import ImageCaptcha from common_library.json_serialization import json_dumps from models_library.emails import LowerCaseEmailStr +from models_library.products import ProductName from models_library.utils.fastapi_encoders import jsonable_encoder from PIL.Image import Image from pydantic import EmailStr, PositiveInt, TypeAdapter, ValidationError @@ -15,6 +16,8 @@ from ..email.utils import send_email_from_template from ..products import products_web from ..products.models import Product +from ..users import _users_service +from ..users.schemas import PreRegisteredUserGet _logger = logging.getLogger(__name__) @@ -122,3 +125,12 @@ async def create_captcha() -> tuple[str, bytes]: image_data = img_byte_arr.getvalue() return (captcha_text, image_data) + + +async def create_pre_registration( + app: web.Application, profile: PreRegisteredUserGet, product_name: ProductName +): + + await _users_service.pre_register_user( + app, profile=profile, creator_user_id=None, product_name=product_name + ) diff --git a/services/web/server/src/simcore_service_webserver/login/errors.py b/services/web/server/src/simcore_service_webserver/login/errors.py index dc6d9ec54cf..8db82ab55a0 100644 --- a/services/web/server/src/simcore_service_webserver/login/errors.py +++ b/services/web/server/src/simcore_service_webserver/login/errors.py @@ -1,15 +1,4 @@ -import functools -import logging - -from aiohttp import web -from servicelib.aiohttp.typing_extension import Handler -from servicelib.logging_errors import create_troubleshootting_log_kwargs -from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON - from ..errors import WebServerBaseError -from ._constants import MSG_2FA_UNAVAILABLE - -_logger = logging.getLogger(__name__) class LoginError(WebServerBaseError, ValueError): ... @@ -21,28 +10,3 @@ class SendingVerificationSmsError(LoginError): class SendingVerificationEmailError(LoginError): msg_template = "Sending verification email failed. {reason}" - - -def handle_login_exceptions(handler: Handler): - @functools.wraps(handler) - async def wrapper(request: web.Request) -> web.StreamResponse: - try: - return await handler(request) - - except (SendingVerificationSmsError, SendingVerificationEmailError) as exc: - error_code = exc.error_code() - front_end_msg = MSG_2FA_UNAVAILABLE - # in these cases I want to log the cause - _logger.exception( - **create_troubleshootting_log_kwargs( - front_end_msg, - error=exc, - error_code=error_code, - ) - ) - raise web.HTTPServiceUnavailable( - text=front_end_msg, - content_type=MIMETYPE_APPLICATION_JSON, - ) from exc - - return wrapper 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 f49c961b440..acfa107abe6 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 @@ -694,6 +694,7 @@ async def review_user_pre_registration( pre_registration_id: int, reviewed_by: UserID, new_status: AccountRequestStatus, + invitation_extras: dict[str, Any] | None = None, ) -> None: """Updates the account request status of a pre-registered user. @@ -703,19 +704,44 @@ async def review_user_pre_registration( pre_registration_id: ID of the pre-registration record reviewed_by: ID of the user who reviewed the request new_status: New status (APPROVED or REJECTED) + invitation_extras: Optional invitation data to store in extras field """ if new_status not in (AccountRequestStatus.APPROVED, AccountRequestStatus.REJECTED): msg = f"Invalid status for review: {new_status}. Must be APPROVED or REJECTED." raise ValueError(msg) async with transaction_context(engine, connection) as conn: + # Base update values + update_values = { + "account_request_status": new_status, + "account_request_reviewed_by": reviewed_by, + "account_request_reviewed_at": sa.func.now(), + } + + # Add invitation extras to the existing extras if provided + if invitation_extras is not None: + assert list(invitation_extras.keys()) == ["invitation"] # nosec + + # Get the current extras first + current_extras_result = await conn.execute( + sa.select(users_pre_registration_details.c.extras).where( + users_pre_registration_details.c.id == pre_registration_id + ) + ) + current_extras_row = current_extras_result.one_or_none() + current_extras = ( + current_extras_row.extras + if current_extras_row and current_extras_row.extras + else {} + ) + + # Merge with invitation extras + merged_extras = {**current_extras, **invitation_extras} + update_values["extras"] = merged_extras + await conn.execute( users_pre_registration_details.update() - .values( - account_request_status=new_status, - account_request_reviewed_by=reviewed_by, - account_request_reviewed_at=sa.func.now(), - ) + .values(**update_values) .where(users_pre_registration_details.c.id == pre_registration_id) ) @@ -766,6 +792,7 @@ async def search_merged_pre_and_registered_users( users_pre_registration_details.c.account_request_reviewed_at, users.c.status, invited_by, + users_pre_registration_details.c.created, ) join_condition = users.c.id == users_pre_registration_details.c.user_id diff --git a/services/web/server/src/simcore_service_webserver/users/_users_rest.py b/services/web/server/src/simcore_service_webserver/users/_users_rest.py index abb8ff7365d..b96623ad56d 100644 --- a/services/web/server/src/simcore_service_webserver/users/_users_rest.py +++ b/services/web/server/src/simcore_service_webserver/users/_users_rest.py @@ -5,6 +5,7 @@ from aiohttp import web from common_library.user_messages import user_message from common_library.users_enums import AccountRequestStatus +from models_library.api_schemas_invitations.invitations import ApiInvitationInputs from models_library.api_schemas_webserver.users import ( MyProfileGet, MyProfilePatch, @@ -23,6 +24,7 @@ parse_request_body_as, parse_request_query_parameters_as, ) +from servicelib.logging_utils import log_context from servicelib.rest_constants import RESPONSE_MODEL_POLICY from .._meta import API_VTAG @@ -34,6 +36,7 @@ ) from ..groups import api as groups_api from ..groups.exceptions import GroupNotFoundError +from ..invitations import api as invitations_service from ..login.decorators import login_required from ..products import products_web from ..products.models import Product @@ -223,7 +226,10 @@ async def list_users_accounts(request: web.Request) -> web.Response: def _to_domain_model(user: dict[str, Any]) -> UserAccountGet: return UserAccountGet( - extras=user.pop("extras") or {}, pre_registration_id=user.pop("id"), **user + extras=user.pop("extras") or {}, + pre_registration_id=user.pop("id"), + pre_registration_created=user.pop("created"), + **user, ) page = Page[UserAccountGet].model_validate( @@ -295,16 +301,41 @@ async def approve_user_account(request: web.Request) -> web.Response: approval_data = await parse_request_body_as(UserAccountApprove, request) + invitation_extras = None if approval_data.invitation: - _logger.debug( - "TODO: User is being approved with invitation %s: \n" - "1. Approve user account\n" - "2. Generate invitation\n" - "3. Store invitation in extras\n" - "4. Send invitation to user %s\n", + with log_context( + _logger, + logging.DEBUG, + "User is being approved with invitation %s for user %s", approval_data.invitation.model_dump_json(indent=1), approval_data.email, - ) + ): + # Generate invitation + invitation_params = ApiInvitationInputs( + issuer=str(req_ctx.user_id), + guest=approval_data.email, + trial_account_days=approval_data.invitation.trial_account_days, + extra_credits_in_usd=approval_data.invitation.extra_credits_in_usd, + ) + + invitation_result = await invitations_service.generate_invitation( + request.app, params=invitation_params + ) + + assert ( # nosec + invitation_result.extra_credits_in_usd + == approval_data.invitation.extra_credits_in_usd + ) + assert ( # nosec + invitation_result.trial_account_days + == approval_data.invitation.trial_account_days + ) + assert invitation_result.guest == approval_data.email # nosec + + # Store invitation data in extras + invitation_extras = { + "invitation": invitation_result.model_dump(mode="json") + } # Approve the user account, passing the current user's ID as the reviewer pre_registration_id = await _users_service.approve_user_account( @@ -312,9 +343,15 @@ async def approve_user_account(request: web.Request) -> web.Response: pre_registration_email=approval_data.email, product_name=req_ctx.product_name, reviewer_id=req_ctx.user_id, + invitation_extras=invitation_extras, ) assert pre_registration_id # nosec + if invitation_extras: + _logger.debug( + "Sending invitation email for user %s [STILL MISSING]", approval_data.email + ) + return web.json_response(status=status.HTTP_204_NO_CONTENT) 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 b7a6668244c..e812c9e8d05 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 @@ -45,7 +45,7 @@ async def pre_register_user( app: web.Application, *, profile: PreRegisteredUserGet, - creator_user_id: UserID, + creator_user_id: UserID | None, product_name: ProductName, ) -> UserAccountGet: @@ -458,6 +458,7 @@ async def _list_products_or_none(user_id): extras=r.extras or {}, invited_by=r.invited_by, pre_registration_id=r.id, + pre_registration_created=r.created, account_request_status=r.account_request_status, account_request_reviewed_by=r.account_request_reviewed_by, account_request_reviewed_at=r.account_request_reviewed_at, @@ -476,6 +477,7 @@ async def approve_user_account( pre_registration_email: LowerCaseEmailStr, product_name: ProductName, reviewer_id: UserID, + invitation_extras: dict[str, Any] | None = None, ) -> int: """Approve a user account based on their pre-registration email. @@ -516,6 +518,7 @@ async def approve_user_account( pre_registration_id=pre_registration_id, reviewed_by=reviewer_id, new_status=AccountRequestStatus.APPROVED, + invitation_extras=invitation_extras, ) return pre_registration_id diff --git a/services/web/server/src/simcore_service_webserver/users/api.py b/services/web/server/src/simcore_service_webserver/users/api.py index f19108debe5..7a581fad556 100644 --- a/services/web/server/src/simcore_service_webserver/users/api.py +++ b/services/web/server/src/simcore_service_webserver/users/api.py @@ -38,8 +38,8 @@ "get_user_role", "get_users_in_group", "is_user_in_product", - "set_user_as_deleted", "search_public_users", + "set_user_as_deleted", "update_expired_users", ) # nopycln: file diff --git a/services/web/server/src/simcore_service_webserver/users/schemas.py b/services/web/server/src/simcore_service_webserver/users/schemas.py new file mode 100644 index 00000000000..c15f6c3c359 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/users/schemas.py @@ -0,0 +1,6 @@ +from ._common.schemas import PreRegisteredUserGet + +__all__: tuple[str, ...] = ("PreRegisteredUserGet",) + + +# nopycln: file diff --git a/services/web/server/tests/unit/with_dbs/03/test_users_rest_registration.py b/services/web/server/tests/unit/with_dbs/03/invitations/test_users_rest_registration.py similarity index 65% rename from services/web/server/tests/unit/with_dbs/03/test_users_rest_registration.py rename to services/web/server/tests/unit/with_dbs/03/invitations/test_users_rest_registration.py index ed41138d5b8..394c3fa6b4a 100644 --- a/services/web/server/tests/unit/with_dbs/03/test_users_rest_registration.py +++ b/services/web/server/tests/unit/with_dbs/03/invitations/test_users_rest_registration.py @@ -23,6 +23,7 @@ ) from models_library.products import ProductName from models_library.rest_pagination import Page +from pytest_simcore.aioresponses_mocker import AioResponsesMock from pytest_simcore.helpers.assert_checks import assert_status from pytest_simcore.helpers.faker_factories import ( DEFAULT_TEST_PASSWORD, @@ -457,3 +458,263 @@ async def test_reject_user_account( ) # Should fail as the account is already reviewed assert resp.status == status.HTTP_400_BAD_REQUEST + + +@pytest.mark.parametrize( + "user_role", + [ + UserRole.PRODUCT_OWNER, + ], +) +async def test_approve_user_account_with_full_invitation_details( + client: TestClient, + logged_user: UserInfoDict, + account_request_form: dict[str, Any], + faker: Faker, + product_name: ProductName, + pre_registration_details_db_cleanup: None, + mock_invitations_service_http_api: AioResponsesMock, +): + """Test approving user account with complete invitation details (trial days + credits)""" + assert client.app + + test_email = faker.email() + + # 1. Create a pre-registered user + form_data = account_request_form.copy() + form_data["firstName"] = faker.first_name() + form_data["lastName"] = faker.last_name() + form_data["email"] = test_email + + resp = await client.post( + "/v0/admin/user-accounts:pre-register", + json=form_data, + headers={X_PRODUCT_NAME_HEADER: product_name}, + ) + await assert_status(resp, status.HTTP_200_OK) + + # 2. Approve the user with full invitation details + approval_payload = { + "email": test_email, + "invitation": { + "trialAccountDays": 30, + "extraCreditsInUsd": 100.0, + }, + } + + url = client.app.router["approve_user_account"].url_for() + resp = await client.post( + f"{url}", + headers={X_PRODUCT_NAME_HEADER: product_name}, + json=approval_payload, + ) + await assert_status(resp, status.HTTP_204_NO_CONTENT) + + # 3. Verify the user account status and invitation data in extras + resp = await client.get( + "/v0/admin/user-accounts:search", + params={"email": test_email}, + headers={X_PRODUCT_NAME_HEADER: product_name}, + ) + found, _ = await assert_status(resp, status.HTTP_200_OK) + assert len(found) == 1 + + user_data = found[0] + assert user_data["accountRequestStatus"] == "APPROVED" + assert user_data["accountRequestReviewedBy"] == logged_user["id"] + assert user_data["accountRequestReviewedAt"] is not None + + # 4. Verify invitation data is stored in extras + assert "invitation" in user_data["extras"] + invitation_data = user_data["extras"]["invitation"] + assert invitation_data["guest"] == test_email + assert invitation_data["issuer"] == str(logged_user["id"]) + assert invitation_data["trial_account_days"] == 30 + assert invitation_data["extra_credits_in_usd"] == 100.0 + assert "invitation_url" in invitation_data + + +@pytest.mark.parametrize( + "user_role", + [UserRole.PRODUCT_OWNER], +) +async def test_approve_user_account_with_trial_days_only( + client: TestClient, + logged_user: UserInfoDict, + account_request_form: dict[str, Any], + faker: Faker, + product_name: ProductName, + pre_registration_details_db_cleanup: None, + mock_invitations_service_http_api: AioResponsesMock, +): + """Test approving user account with only trial days""" + assert client.app + + test_email = faker.email() + + # 1. Create a pre-registered user + form_data = account_request_form.copy() + form_data["firstName"] = faker.first_name() + form_data["lastName"] = faker.last_name() + form_data["email"] = test_email + + resp = await client.post( + "/v0/admin/user-accounts:pre-register", + json=form_data, + headers={X_PRODUCT_NAME_HEADER: product_name}, + ) + await assert_status(resp, status.HTTP_200_OK) + + # 2. Approve the user with only trial days + approval_payload = { + "email": test_email, + "invitation": { + "trialAccountDays": 15, + # No extra_credits_in_usd + }, + } + + url = client.app.router["approve_user_account"].url_for() + resp = await client.post( + f"{url}", + headers={X_PRODUCT_NAME_HEADER: product_name}, + json=approval_payload, + ) + await assert_status(resp, status.HTTP_204_NO_CONTENT) + + # 3. Verify invitation data in extras + resp = await client.get( + "/v0/admin/user-accounts:search", + params={"email": test_email}, + headers={X_PRODUCT_NAME_HEADER: product_name}, + ) + found, _ = await assert_status(resp, status.HTTP_200_OK) + user_data = found[0] + + assert "invitation" in user_data["extras"] + invitation_data = user_data["extras"]["invitation"] + assert invitation_data["trial_account_days"] == 15 + assert invitation_data["extra_credits_in_usd"] is None + + +@pytest.mark.parametrize( + "user_role", + [UserRole.PRODUCT_OWNER], +) +async def test_approve_user_account_with_credits_only( + client: TestClient, + logged_user: UserInfoDict, + account_request_form: dict[str, Any], + faker: Faker, + product_name: ProductName, + pre_registration_details_db_cleanup: None, + mock_invitations_service_http_api: AioResponsesMock, +): + """Test approving user account with only extra credits""" + assert client.app + + test_email = faker.email() + + # 1. Create a pre-registered user + form_data = account_request_form.copy() + form_data["firstName"] = faker.first_name() + form_data["lastName"] = faker.last_name() + form_data["email"] = test_email + + resp = await client.post( + "/v0/admin/user-accounts:pre-register", + json=form_data, + headers={X_PRODUCT_NAME_HEADER: product_name}, + ) + await assert_status(resp, status.HTTP_200_OK) + + # 2. Approve the user with only extra credits + approval_payload = { + "email": test_email, + "invitation": { + # No trial_account_days + "extraCreditsInUsd": 50.0, + }, + } + + url = client.app.router["approve_user_account"].url_for() + resp = await client.post( + f"{url}", + headers={X_PRODUCT_NAME_HEADER: product_name}, + json=approval_payload, + ) + await assert_status(resp, status.HTTP_204_NO_CONTENT) + + # 3. Verify invitation data in extras + resp = await client.get( + "/v0/admin/user-accounts:search", + params={"email": test_email}, + headers={X_PRODUCT_NAME_HEADER: product_name}, + ) + found, _ = await assert_status(resp, status.HTTP_200_OK) + user_data = found[0] + + assert "invitation" in user_data["extras"] + invitation_data = user_data["extras"]["invitation"] + assert invitation_data["trial_account_days"] is None + assert invitation_data["extra_credits_in_usd"] == 50.0 + + +@pytest.mark.parametrize( + "user_role", + [ + UserRole.PRODUCT_OWNER, + ], +) +async def test_approve_user_account_without_invitation( + client: TestClient, + logged_user: UserInfoDict, + account_request_form: dict[str, Any], + faker: Faker, + product_name: ProductName, + pre_registration_details_db_cleanup: None, +): + """Test approving user account without any invitation details""" + assert client.app + + test_email = faker.email() + + # 1. Create a pre-registered user + form_data = account_request_form.copy() + form_data["firstName"] = faker.first_name() + form_data["lastName"] = faker.last_name() + form_data["email"] = test_email + + resp = await client.post( + "/v0/admin/user-accounts:pre-register", + json=form_data, + headers={X_PRODUCT_NAME_HEADER: product_name}, + ) + await assert_status(resp, status.HTTP_200_OK) + + # 2. Approve the user without invitation + approval_payload = { + "email": test_email, + # No invitation field + } + + url = client.app.router["approve_user_account"].url_for() + resp = await client.post( + f"{url}", + headers={X_PRODUCT_NAME_HEADER: product_name}, + json=approval_payload, + ) + await assert_status(resp, status.HTTP_204_NO_CONTENT) + + # 3. Verify no invitation data in extras + resp = await client.get( + "/v0/admin/user-accounts:search", + params={"email": test_email}, + headers={X_PRODUCT_NAME_HEADER: product_name}, + ) + found, _ = await assert_status(resp, status.HTTP_200_OK) + user_data = found[0] + + assert user_data["accountRequestStatus"] == "APPROVED" + # Verify no invitation data stored + assert "invitation" not in user_data["extras"] diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_preregistration.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_preregistration.py index c7d539d7a8e..9418b324c64 100644 --- a/services/web/server/tests/unit/with_dbs/03/login/test_login_preregistration.py +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_preregistration.py @@ -10,10 +10,13 @@ import pytest from aiohttp import ClientResponseError from aiohttp.test_utils import TestClient +from common_library.users_enums import AccountRequestStatus from faker import Faker from models_library.api_schemas_webserver.auth import AccountRequestInfo +from models_library.api_schemas_webserver.users import UserAccountGet from pytest_mock import MockerFixture from pytest_simcore.helpers.assert_checks import assert_status +from pytest_simcore.helpers.webserver_login import switch_client_session_to from pytest_simcore.helpers.webserver_users import NewUser, UserInfoDict from servicelib.aiohttp import status from simcore_postgres_database.models.users import UserRole @@ -57,7 +60,7 @@ def mocked_send_email(mocker: MockerFixture) -> MagicMock: @pytest.fixture def mocked_captcha_session(mocker: MockerFixture) -> MagicMock: return mocker.patch( - "simcore_service_webserver.login._controller.rest.preregistration.get_session", + "simcore_service_webserver.login._controller.rest.preregistration.session_service.get_session", spec=True, return_value={"captcha": "123456"}, ) @@ -168,7 +171,7 @@ async def test_request_an_account( assert client.app # A form similar to the one in https://github.com/ITISFoundation/osparc-simcore/pull/5378 user_data = { - **AccountRequestInfo.model_config["json_schema_extra"]["example"]["form"], + **AccountRequestInfo.model_json_schema()["example"]["form"], # fields required in the form "firstName": faker.first_name(), "lastName": faker.last_name(), @@ -188,8 +191,33 @@ async def test_request_an_account( product = get_product(client.app, product_name="osparc") - # sent email? + # check email was sent mimetext = mocked_send_email.call_args[1]["message"] assert "account" in mimetext["Subject"].lower() assert mimetext["From"] == product.support_email assert mimetext["To"] == product.product_owners_email or product.support_email + + # check it appears in PO center + async with NewUser( + user_data={ + "email": "po-user@email.com", + "name": "po-user-fixture", + "role": UserRole.PRODUCT_OWNER, + }, + app=client.app, + ) as product_owner_user, switch_client_session_to(client, product_owner_user): + + response = await client.get( + "v0/admin/user-accounts?limit=20&offset=0&review_status=PENDING" + ) + + data, _ = await assert_status(response, status.HTTP_200_OK) + + assert len(data) == 1 + user = UserAccountGet.model_validate(data[0]) + assert user.first_name == user_data["firstName"] + assert not user.registered + assert user.status is None + assert user.account_request_status == AccountRequestStatus.PENDING + + # TODO add a test for reregistration `AlreadyPreRegisteredError` diff --git a/services/web/server/tests/unit/with_dbs/03/login/test_login_twofa.py b/services/web/server/tests/unit/with_dbs/03/login/test_login_twofa.py index c84bc7ab5ac..b91b30075ce 100644 --- a/services/web/server/tests/unit/with_dbs/03/login/test_login_twofa.py +++ b/services/web/server/tests/unit/with_dbs/03/login/test_login_twofa.py @@ -463,7 +463,7 @@ async def test_2fa_sms_failure_during_login( response, status.HTTP_503_SERVICE_UNAVAILABLE ) assert not data - assert error["errors"][0]["message"].startswith(MSG_2FA_UNAVAILABLE[:10]) + assert error["message"].startswith(MSG_2FA_UNAVAILABLE[:10]) # Expects logs like 'Failed while setting up 2FA code and sending SMS to 157XXXXXXXX3 [OEC:140392495277888]' assert f"{user_phone_number[:3]}" in caplog.text diff --git a/services/web/server/tests/unit/with_dbs/03/users/test_users_repository.py b/services/web/server/tests/unit/with_dbs/03/users/test_users_repository.py index 48d1991a1b9..d10a8fa4a35 100644 --- a/services/web/server/tests/unit/with_dbs/03/users/test_users_repository.py +++ b/services/web/server/tests/unit/with_dbs/03/users/test_users_repository.py @@ -170,6 +170,96 @@ async def test_review_user_pre_registration( await conn.commit() +async def test_review_user_pre_registration_with_invitation_extras( + app: web.Application, + product_name: ProductName, + product_owner_user: dict[str, Any], + pre_registration_details_db_cleanup: list[int], +): + # Arrange + asyncpg_engine = get_asyncpg_engine(app) + + test_email = "review.with.invitation@example.com" + created_by_user_id = product_owner_user["id"] + reviewer_id = product_owner_user["id"] + institution = "Test Institution" + pre_registration_details: dict[str, Any] = { + "institution": institution, + "pre_first_name": "Review", + "pre_last_name": "WithInvitation", + } + + # Create a pre-registration to review + pre_registration_id = await _users_repository.create_user_pre_registration( + asyncpg_engine, + email=test_email, + created_by=created_by_user_id, + product_name=product_name, + **pre_registration_details, + ) + + # Add to cleanup list + pre_registration_details_db_cleanup.append(pre_registration_id) + + # Prepare invitation extras (mimicking the structure from _users_rest.py) + invitation_extras = { + "invitation": { + "issuer": str(reviewer_id), + "guest": test_email, + "trial_account_days": 30, + "extra_credits_in_usd": 100.0, + "product_name": product_name, + "created": "2024-01-01T00:00:00Z", + } + } + + # Act - review and approve the registration with invitation extras + new_status = AccountRequestStatus.APPROVED + await _users_repository.review_user_pre_registration( + asyncpg_engine, + pre_registration_id=pre_registration_id, + reviewed_by=reviewer_id, + new_status=new_status, + invitation_extras=invitation_extras, + ) + + # Assert - Use list_user_pre_registrations to verify + registrations, count = await _users_repository.list_user_pre_registrations( + asyncpg_engine, + filter_by_pre_email=test_email, + filter_by_product_name=product_name, + ) + + # Check count and that we found our registration + assert count == 1 + assert len(registrations) == 1 + + # Get the registration + reg = registrations[0] + + # Verify basic details + assert reg["id"] == pre_registration_id + assert reg["pre_email"] == test_email + assert reg["pre_first_name"] == "Review" + assert reg["pre_last_name"] == "WithInvitation" + assert reg["institution"] == institution + assert reg["product_name"] == product_name + assert reg["account_request_status"] == new_status + assert reg["created_by"] == created_by_user_id + assert reg["account_request_reviewed_by"] == reviewer_id + assert reg["account_request_reviewed_at"] is not None + + # Verify invitation extras were stored correctly + assert reg["extras"] is not None + assert "invitation" in reg["extras"] + invitation_data = reg["extras"]["invitation"] + assert invitation_data["issuer"] == str(reviewer_id) + assert invitation_data["guest"] == test_email + assert invitation_data["trial_account_days"] == 30 + assert invitation_data["extra_credits_in_usd"] == 100.0 + assert invitation_data["product_name"] == product_name + + async def test_list_user_pre_registrations( app: web.Application, product_name: ProductName,