From 1a4b6144153ec89af32b6a377f1d7e069ccaa73f Mon Sep 17 00:00:00 2001 From: Pedro Crespo-Valero <32402063+pcrespov@users.noreply.github.com> Date: Thu, 16 Nov 2023 12:30:55 +0100 Subject: [PATCH] =?UTF-8?q?=20=E2=9C=A8=20Invitations=20to=20register=20to?= =?UTF-8?q?=20product=20(#4739)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../invitations/_core.py | 56 +++++++++++- .../invitations/plugin.py | 1 + .../login/_auth_api.py | 60 ++++++++++++ .../login/_registration.py | 6 ++ .../login/handlers_auth.py | 35 ++----- .../login/handlers_registration.py | 74 ++++++++++----- .../simcore_service_webserver/login/utils.py | 4 + .../products/_api.py | 2 +- .../products/_events.py | 7 +- .../products/_middlewares.py | 3 +- .../unit/with_dbs/03/invitations/conftest.py | 40 ++++++-- .../03/invitations/test_invitations.py | 91 ++++++++++++------- .../test_login_registration_invitations.py | 4 +- 13 files changed, 284 insertions(+), 99 deletions(-) create mode 100644 services/web/server/src/simcore_service_webserver/login/_auth_api.py diff --git a/services/web/server/src/simcore_service_webserver/invitations/_core.py b/services/web/server/src/simcore_service_webserver/invitations/_core.py index e2741148047..ded31e6f7ab 100644 --- a/services/web/server/src/simcore_service_webserver/invitations/_core.py +++ b/services/web/server/src/simcore_service_webserver/invitations/_core.py @@ -9,11 +9,14 @@ ApiInvitationContentAndLink, ApiInvitationInputs, ) +from models_library.users import GroupID from pydantic import AnyHttpUrl, ValidationError, parse_obj_as from servicelib.error_codes import create_error_code +from simcore_postgres_database.models.groups import user_to_groups from simcore_postgres_database.models.users import users from ..db.plugin import get_database_engine +from ..products.api import Product from ._client import InvitationsServiceApi, get_invitations_service_api from .errors import ( MSG_INVALID_INVITATION_URL, @@ -26,14 +29,31 @@ _logger = logging.getLogger(__name__) -async def _is_user_registered(app: web.Application, email: str) -> bool: +async def _is_user_registered_in_platform(app: web.Application, email: str) -> bool: pg_engine = get_database_engine(app=app) - async with pg_engine.acquire() as conn: user_id = await conn.scalar(sa.select(users.c.id).where(users.c.email == email)) return user_id is not None +async def _is_user_registered_in_product( + app: web.Application, email: str, product_group_id: GroupID +) -> bool: + pg_engine = get_database_engine(app=app) + + async with pg_engine.acquire() as conn: + user_id = await conn.scalar( + sa.select(users.c.id) + .select_from( + sa.join(user_to_groups, users, user_to_groups.c.uid == users.c.id) + ) + .where( + (users.c.email == email) & (user_to_groups.c.gid == product_group_id) + ) + ) + return user_id is not None + + @contextmanager def _handle_exceptions_as_invitations_errors(): try: @@ -56,6 +76,7 @@ def _handle_exceptions_as_invitations_errors(): raise InvitationsServiceUnavailable from err except (ValidationError, ClientError) as err: + _logger.debug("Invitations error %s", f"{err}") raise InvitationsServiceUnavailable from err except InvitationsErrors: @@ -80,12 +101,21 @@ def is_service_invitation_code(code: str): async def validate_invitation_url( - app: web.Application, guest_email: str, invitation_url: str + app: web.Application, + *, + current_product: Product, + guest_email: str, + invitation_url: str, ) -> ApiInvitationContent: """Validates invitation and associated email/user and returns content upon success raises InvitationsError """ + if current_product.group_id is None: + raise InvitationsServiceUnavailable( + reason="Current product is not configured for invitations" + ) + invitations_service: InvitationsServiceApi = get_invitations_service_api(app=app) with _handle_exceptions_as_invitations_errors(): @@ -99,13 +129,29 @@ async def validate_invitation_url( invitation_url=valid_url ) + # check email if invitation.guest != guest_email: raise InvalidInvitation( reason="This invitation was issued for a different email" ) - # existing users cannot be re-invited - if await _is_user_registered(app=app, email=invitation.guest): + # check product + assert current_product.group_id is not None # nosec + if ( + invitation.product is not None + and invitation.product != current_product.name + ): + raise InvalidInvitation( + reason="This invitation was issued for a different product. " + f"Got '{invitation.product}', expected '{current_product.name}'" + ) + + # check invitation used + assert invitation.product == current_product.name # nosec + if await _is_user_registered_in_product( + app=app, email=invitation.guest, product_group_id=current_product.group_id + ): + # NOTE: a user might be already registered but the invitation is for another product raise InvalidInvitation(reason=MSG_INVITATION_ALREADY_USED) return invitation diff --git a/services/web/server/src/simcore_service_webserver/invitations/plugin.py b/services/web/server/src/simcore_service_webserver/invitations/plugin.py index 1b865c9dac4..fd20f0f8601 100644 --- a/services/web/server/src/simcore_service_webserver/invitations/plugin.py +++ b/services/web/server/src/simcore_service_webserver/invitations/plugin.py @@ -10,6 +10,7 @@ from .._constants import APP_SETTINGS_KEY from ..db.plugin import setup_db +from ..products.plugin import setup_products from ._client import invitations_service_api_cleanup_ctx _logger = logging.getLogger(__name__) diff --git a/services/web/server/src/simcore_service_webserver/login/_auth_api.py b/services/web/server/src/simcore_service_webserver/login/_auth_api.py new file mode 100644 index 00000000000..d22562b147d --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/login/_auth_api.py @@ -0,0 +1,60 @@ +from datetime import datetime + +from aiohttp import web +from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON + +from ..products.api import Product +from ..security.api import check_password, encrypt_password +from ._constants import MSG_UNKNOWN_EMAIL, MSG_WRONG_PASSWORD +from .storage import AsyncpgStorage, get_plugin_storage +from .utils import USER, get_user_name_from_email, validate_user_status + + +async def get_user_by_email(app: web.Application, *, email: str) -> dict: + db: AsyncpgStorage = get_plugin_storage(app) + user: dict = await db.get_user({"email": email}) + return user + + +async def create_user( + app: web.Application, + *, + email: str, + password: str, + status: str, + expires_at: datetime | None +) -> dict: + db: AsyncpgStorage = get_plugin_storage(app) + + user: dict = await db.create_user( + { + "name": get_user_name_from_email(email), + "email": email, + "password_hash": encrypt_password(password), + "status": status, + "role": USER, + "expires_at": expires_at, + } + ) + return user + + +async def check_authorized_user_or_raise( + user: dict, + password: str, + product: Product, +) -> dict: + + if not user: + raise web.HTTPUnauthorized( + reason=MSG_UNKNOWN_EMAIL, content_type=MIMETYPE_APPLICATION_JSON + ) + + validate_user_status(user=user, support_email=product.support_email) + + if not check_password(password, user["password_hash"]): + raise web.HTTPUnauthorized( + reason=MSG_WRONG_PASSWORD, content_type=MIMETYPE_APPLICATION_JSON + ) + + return user diff --git a/services/web/server/src/simcore_service_webserver/login/_registration.py b/services/web/server/src/simcore_service_webserver/login/_registration.py index 0f644548b0e..bfc1c9d0bab 100644 --- a/services/web/server/src/simcore_service_webserver/login/_registration.py +++ b/services/web/server/src/simcore_service_webserver/login/_registration.py @@ -12,6 +12,7 @@ from aiohttp import web from models_library.basic_types import IdInt from models_library.emails import LowerCaseEmailStr +from models_library.products import ProductName from pydantic import BaseModel, Field, Json, PositiveInt, ValidationError, validator from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON from simcore_postgres_database.models.confirmations import ConfirmationAction @@ -23,6 +24,7 @@ validate_invitation_url, ) from ..invitations.errors import InvalidInvitation, InvitationsServiceUnavailable +from ..products.api import Product from ._confirmation import is_confirmation_expired, validate_confirmation_code from ._constants import MSG_EMAIL_EXISTS, MSG_INVITATIONS_CONTACT_SUFFIX from .settings import LoginOptions @@ -51,6 +53,7 @@ class InvitationData(BaseModel): "Sets the number of days from creation until the account expires", ) extra_credits_in_usd: PositiveInt | None = None + product: ProductName | None = None class _InvitationValidator(BaseModel): @@ -190,6 +193,7 @@ async def extract_email_from_invitation( async def check_and_consume_invitation( invitation_code: str, guest_email: str, + product: Product, db: AsyncpgStorage, cfg: LoginOptions, app: web.Application, @@ -207,6 +211,7 @@ async def check_and_consume_invitation( with _invitations_request_context(invitation_code=invitation_code) as url: content = await validate_invitation_url( app, + current_product=product, guest_email=guest_email, invitation_url=f"{url}", ) @@ -219,6 +224,7 @@ async def check_and_consume_invitation( guest=content.guest, trial_account_days=content.trial_account_days, extra_credits_in_usd=content.extra_credits_in_usd, + product=content.product, ) # database-type invitations diff --git a/services/web/server/src/simcore_service_webserver/login/handlers_auth.py b/services/web/server/src/simcore_service_webserver/login/handlers_auth.py index 4cde1cb741d..b18dff0ae45 100644 --- a/services/web/server/src/simcore_service_webserver/login/handlers_auth.py +++ b/services/web/server/src/simcore_service_webserver/login/handlers_auth.py @@ -14,7 +14,7 @@ from .._meta import API_VTAG from ..products.api import Product, get_current_product -from ..security.api import check_password, forget +from ..security.api import forget from ..session.access_policies import ( on_success_grant_session_access_to, session_access_required, @@ -27,6 +27,7 @@ mask_phone_number, send_sms_code, ) +from ._auth_api import check_authorized_user_or_raise, get_user_by_email from ._constants import ( CODE_2FA_CODE_REQUIRED, CODE_PHONE_NUMBER_REQUIRED, @@ -37,22 +38,14 @@ MSG_LOGGED_OUT, MSG_PHONE_MISSING, MSG_UNAUTHORIZED_LOGIN_2FA, - MSG_UNKNOWN_EMAIL, MSG_WRONG_2FA_CODE, - MSG_WRONG_PASSWORD, ) from ._models import InputSchema from ._security import login_granted_response from .decorators import login_required from .settings import LoginSettingsForProduct, get_plugin_settings from .storage import AsyncpgStorage, get_plugin_storage -from .utils import ( - ACTIVE, - envelope_response, - flash_response, - notify_user_logout, - validate_user_status, -) +from .utils import envelope_response, flash_response, notify_user_logout log = logging.getLogger(__name__) @@ -98,25 +91,13 @@ async def login(request: web.Request): settings: LoginSettingsForProduct = get_plugin_settings( request.app, product_name=product.name ) - db: AsyncpgStorage = get_plugin_storage(request.app) - login_ = await parse_request_body_as(LoginBody, request) - user = await db.get_user({"email": login_.email}) - if not user: - raise web.HTTPUnauthorized( - reason=MSG_UNKNOWN_EMAIL, content_type=MIMETYPE_APPLICATION_JSON - ) - - validate_user_status(user=user, support_email=product.support_email) - - if not check_password(login_.password.get_secret_value(), user["password_hash"]): - raise web.HTTPUnauthorized( - reason=MSG_WRONG_PASSWORD, content_type=MIMETYPE_APPLICATION_JSON - ) - - assert user["status"] == ACTIVE, "db corrupted. Invalid status" # nosec - assert user["email"] == login_.email, "db corrupted. Invalid email" # nosec + user = await check_authorized_user_or_raise( + user=await get_user_by_email(request.app, email=login_.email), + password=login_.password.get_secret_value(), + product=product, + ) # Some roles have login privileges has_privileges: Final[bool] = UserRole(user["role"]) > UserRole.USER diff --git a/services/web/server/src/simcore_service_webserver/login/handlers_registration.py b/services/web/server/src/simcore_service_webserver/login/handlers_registration.py index 601b3563217..24e1a5fa85b 100644 --- a/services/web/server/src/simcore_service_webserver/login/handlers_registration.py +++ b/services/web/server/src/simcore_service_webserver/login/handlers_registration.py @@ -14,7 +14,6 @@ from ..groups.api import auto_add_user_to_groups, auto_add_user_to_product_group from ..invitations.api import is_service_invitation_code from ..products.api import Product, get_current_product -from ..security.api import encrypt_password from ..session.access_policies import ( on_success_grant_session_access_to, session_access_required, @@ -22,6 +21,7 @@ from ..utils import MINUTE from ..utils_aiohttp import NextPage, envelope_json_response from ..utils_rate_limiting import global_rate_limit_route +from . import _auth_api from ._2fa import create_2fa_code, mask_phone_number, send_sms_code from ._confirmation import make_confirmation_link from ._constants import ( @@ -51,9 +51,9 @@ ACTIVE, CONFIRMATION_PENDING, REGISTRATION, - USER, envelope_response, flash_response, + get_user_name_from_email, notify_user_confirmation, ) from .utils_email import get_template_path, send_email_from_template @@ -61,10 +61,6 @@ log = logging.getLogger(__name__) -def _get_user_name(email: str) -> str: - return email.split("@")[0] - - routes = RouteTableDef() @@ -168,8 +164,24 @@ async def register(request: web.Request): content_type=MIMETYPE_APPLICATION_JSON, ) + # INVITATIONS expires_at: datetime | None = None # = does not expire invitation = None + # There are 3 possible states for an invitation: + # 1. Invitation is not required (i.e. the app has disabled invitations) + # 2. Invitation is invalid + # 3. Invitation is valid + # + # For those states the `invitation` variable get the following values + # 1. `None + # 2. no value, it raises and exception + # 3. gets `InvitationData` + # ` + # In addition, for 3. there are two types of invitations: + # 1. the invitation generated by the `invitation` service (new). + # 2. the invitation created by hand in the db confirmation table (deprecated). This + # one does not understand products. + # if settings.LOGIN_REGISTRATION_INVITATION_REQUIRED: # Only requests with INVITATION can register user # to either a permanent or to a trial account @@ -182,6 +194,7 @@ async def register(request: web.Request): invitation = await check_and_consume_invitation( invitation_code, + product=product, guest_email=registration.email, db=db, cfg=cfg, @@ -190,26 +203,39 @@ async def register(request: web.Request): if invitation.trial_account_days: expires_at = datetime.utcnow() + timedelta(invitation.trial_account_days) - username = _get_user_name(registration.email) - user: dict = await db.create_user( - { - "name": username, - "email": registration.email, - "password_hash": encrypt_password(registration.password.get_secret_value()), - "status": ( + # get authorized user or create new + user = await _auth_api.get_user_by_email(request.app, email=registration.email) + if user: + await _auth_api.check_authorized_user_or_raise( + user, + password=registration.password.get_secret_value(), + product=product, + ) + else: + user = await _auth_api.create_user( + request.app, + email=registration.email, + password=registration.password.get_secret_value(), + status=( CONFIRMATION_PENDING if settings.LOGIN_REGISTRATION_CONFIRMATION_REQUIRED else ACTIVE ), - "role": USER, - "expires_at": expires_at, - } + expires_at=expires_at, + ) + + # setup user groups + assert ( # nosec + product.name == invitation.product + if invitation and invitation.product + else True ) - # NOTE: PC->SAN: should this go here or when user is actually logged in? await auto_add_user_to_groups(app=request.app, user_id=user["id"]) await auto_add_user_to_product_group( - app=request.app, user_id=user["id"], product_name=product.name + app=request.app, + user_id=user["id"], + product_name=product.name, ) if settings.LOGIN_REGISTRATION_CONFIRMATION_REQUIRED: @@ -231,7 +257,7 @@ async def register(request: web.Request): context={ "host": request.host, "link": email_confirmation_url, # SEE email_confirmation handler (action=REGISTRATION) - "name": username, + "name": user["name"], "support_email": product.support_email, }, ) @@ -259,6 +285,12 @@ async def register(request: web.Request): # NOTE: Here confirmation is disabled assert settings.LOGIN_REGISTRATION_CONFIRMATION_REQUIRED is False # nosec + assert ( # nosec + product.name == invitation.product + if invitation and invitation.product + else True + ) + await notify_user_confirmation( request.app, user_id=user["id"], @@ -331,7 +363,7 @@ async def register_phone(request: web.Request): raise ValueError(msg) if await db.get_user({"phone": registration.phone}): - raise web.HTTPUnauthorized( + raise web.HTTPUnauthorized( # noqa: TRY301 reason="Cannot register this phone number because it is already assigned to an active user", content_type=MIMETYPE_APPLICATION_JSON, ) @@ -347,7 +379,7 @@ async def register_phone(request: web.Request): twilo_auth=settings.LOGIN_TWILIO, twilio_messaging_sid=product.twilio_messaging_sid, twilio_alpha_numeric_sender=product.twilio_alpha_numeric_sender_id, - user_name=_get_user_name(registration.email), + user_name=get_user_name_from_email(registration.email), ) message = MSG_2FA_CODE_SENT.format( diff --git a/services/web/server/src/simcore_service_webserver/login/utils.py b/services/web/server/src/simcore_service_webserver/login/utils.py index 3635225236a..f4732de0df6 100644 --- a/services/web/server/src/simcore_service_webserver/login/utils.py +++ b/services/web/server/src/simcore_service_webserver/login/utils.py @@ -144,3 +144,7 @@ def envelope_response( dumps=json_dumps, status=status, ) + + +def get_user_name_from_email(email: str) -> str: + return email.split("@")[0] diff --git a/services/web/server/src/simcore_service_webserver/products/_api.py b/services/web/server/src/simcore_service_webserver/products/_api.py index 9368627c17a..e124a37eb8e 100644 --- a/services/web/server/src/simcore_service_webserver/products/_api.py +++ b/services/web/server/src/simcore_service_webserver/products/_api.py @@ -30,7 +30,7 @@ def get_current_product(request: web.Request) -> Product: def list_products(app: web.Application) -> list[Product]: - products: list[Product] = app[APP_PRODUCTS_KEY].values() + products: list[Product] = list(app[APP_PRODUCTS_KEY].values()) return products diff --git a/services/web/server/src/simcore_service_webserver/products/_events.py b/services/web/server/src/simcore_service_webserver/products/_events.py index f0165a21cd8..48c23dc1ec1 100644 --- a/services/web/server/src/simcore_service_webserver/products/_events.py +++ b/services/web/server/src/simcore_service_webserver/products/_events.py @@ -1,5 +1,6 @@ import logging import tempfile +from collections import OrderedDict from pathlib import Path from aiohttp import web @@ -59,7 +60,9 @@ async def auto_create_products_groups(app: web.Application) -> None: def _set_app_state( - app: web.Application, app_products: dict[str, Product], default_product_name: str + app: web.Application, + app_products: OrderedDict[str, Product], + default_product_name: str, ): app[APP_PRODUCTS_KEY] = app_products assert default_product_name in app_products # nosec @@ -70,7 +73,7 @@ async def load_products_on_startup(app: web.Application): """ Loads info on products stored in the database into app's storage (i.e. memory) """ - app_products: dict[str, Product] = {} + app_products: OrderedDict[str, Product] = OrderedDict() engine: Engine = app[APP_DB_ENGINE_KEY] async with engine.acquire() as connection: async for row in iter_products(connection): diff --git a/services/web/server/src/simcore_service_webserver/products/_middlewares.py b/services/web/server/src/simcore_service_webserver/products/_middlewares.py index 4c3dbf78d27..1f69a3980f1 100644 --- a/services/web/server/src/simcore_service_webserver/products/_middlewares.py +++ b/services/web/server/src/simcore_service_webserver/products/_middlewares.py @@ -1,4 +1,5 @@ import logging +from collections import OrderedDict from aiohttp import web from servicelib.aiohttp.typing_extension import Handler @@ -12,7 +13,7 @@ def _discover_product_by_hostname(request: web.Request) -> str | None: - products: dict[str, Product] = request.app[APP_PRODUCTS_KEY] + products: OrderedDict[str, Product] = request.app[APP_PRODUCTS_KEY] for product in products.values(): if product.host_regex.search(request.host): product_name: str = product.name diff --git a/services/web/server/tests/unit/with_dbs/03/invitations/conftest.py b/services/web/server/tests/unit/with_dbs/03/invitations/conftest.py index 8779e0af913..6f312bf8a48 100644 --- a/services/web/server/tests/unit/with_dbs/03/invitations/conftest.py +++ b/services/web/server/tests/unit/with_dbs/03/invitations/conftest.py @@ -24,6 +24,7 @@ InvitationsSettings, get_plugin_settings, ) +from simcore_service_webserver.products.api import Product, list_products from yarl import URL @@ -44,13 +45,27 @@ def invitations_service_openapi_specs( @pytest.fixture -def expected_invitation( +def current_product(client: TestClient) -> Product: + assert client.app + products = list_products(client.app) + assert products + assert products[0].name == "osparc" + return products[0] + + +@pytest.fixture +def fake_osparc_invitation( invitations_service_openapi_specs: dict[str, Any] ) -> ApiInvitationContent: + """ + Emulates an invitation for osparc product + """ oas = deepcopy(invitations_service_openapi_specs) - return ApiInvitationContent.parse_obj( + content = ApiInvitationContent.parse_obj( oas["components"]["schemas"]["ApiInvitationContent"]["example"] ) + content.product = "osparc" + return content @pytest.fixture() @@ -63,7 +78,7 @@ def mock_invitations_service_http_api( aioresponses_mocker: AioResponsesMock, invitations_service_openapi_specs: dict[str, Any], base_url: URL, - expected_invitation: ApiInvitationContent, + fake_osparc_invitation: ApiInvitationContent, ) -> AioResponsesMock: oas = deepcopy(invitations_service_openapi_specs) @@ -85,18 +100,24 @@ def mock_invitations_service_http_api( # extract assert "/v1/invitations:extract" in oas["paths"] + + def _extract(url, **kwargs): + return CallbackResult( + status=web.HTTPOk.status_code, + payload=jsonable_encoder(fake_osparc_invitation.dict()), + ) + aioresponses_mocker.post( f"{base_url}/v1/invitations:extract", - status=web.HTTPOk.status_code, - payload=jsonable_encoder(expected_invitation.dict()), + callback=_extract, + repeat=True, # NOTE: this can be used many times ) # generate assert "/v1/invitations" in oas["paths"] example = oas["components"]["schemas"]["ApiInvitationContentAndLink"]["example"] - def _side_effect(url, **kwargs): - + def _generate(url, **kwargs): body = kwargs["json"] assert isinstance(body, dict) if not body.get("product"): @@ -115,6 +136,9 @@ def _side_effect(url, **kwargs): ), ) - aioresponses_mocker.post(f"{base_url}/v1/invitations", callback=_side_effect) + aioresponses_mocker.post( + f"{base_url}/v1/invitations", + callback=_generate, + ) return aioresponses_mocker diff --git a/services/web/server/tests/unit/with_dbs/03/invitations/test_invitations.py b/services/web/server/tests/unit/with_dbs/03/invitations/test_invitations.py index 72330812e85..4eae544d8c1 100644 --- a/services/web/server/tests/unit/with_dbs/03/invitations/test_invitations.py +++ b/services/web/server/tests/unit/with_dbs/03/invitations/test_invitations.py @@ -11,6 +11,7 @@ from pytest_simcore.helpers.utils_envs import EnvVarsDict, setenvs_from_dict from pytest_simcore.helpers.utils_login import NewUser from simcore_service_webserver.application_settings import ApplicationSettings +from simcore_service_webserver.groups.api import auto_add_user_to_product_group from simcore_service_webserver.invitations._client import ( InvitationsServiceApi, get_invitations_service_api, @@ -20,6 +21,7 @@ InvalidInvitation, InvitationsServiceUnavailable, ) +from simcore_service_webserver.products.api import Product from yarl import URL @@ -29,9 +31,17 @@ def app_environment( env_devel_dict: EnvVarsDict, monkeypatch: pytest.MonkeyPatch, ): - envs_plugins = setenvs_from_dict( + # ensures WEBSERVER_INVITATIONS is undefined + monkeypatch.delenv("WEBSERVER_INVITATIONS", raising=False) + app_environment.pop("WEBSERVER_INVITATIONS", None) + + # new envs + envs = setenvs_from_dict( monkeypatch, { + # as before + **app_environment, + # disable these plugins "WEBSERVER_ACTIVITY": "null", "WEBSERVER_DB_LISTENER": "0", "WEBSERVER_DIAGNOSTICS": "null", @@ -47,30 +57,24 @@ def app_environment( "WEBSERVER_TRACING": "null", "WEBSERVER_VERSION_CONTROL": "0", "WEBSERVER_WALLETS": "0", + # set INVITATIONS_* variables using those in .env-devel + **{ + key: value + for key, value in env_devel_dict.items() + if key.startswith("INVITATIONS_") + }, }, ) - # undefine WEBSERVER_INVITATIONS - app_environment.pop("WEBSERVER_INVITATIONS", None) - monkeypatch.delenv("WEBSERVER_INVITATIONS", raising=False) - - # set INVITATIONS_* variables using those in .devel-env - envs_invitations = setenvs_from_dict( - monkeypatch, - envs={ - name: value - for name, value in env_devel_dict.items() - if name.startswith("INVITATIONS_") - }, - ) - + # tests envs print(ApplicationSettings.create_from_envs().json(indent=2)) - return app_environment | envs_plugins | envs_invitations + return envs async def test_invitation_service_unavailable( client: TestClient, - expected_invitation: ApiInvitationContent, + fake_osparc_invitation: ApiInvitationContent, + current_product: Product, ): assert client.app invitations_api: InvitationsServiceApi = get_invitations_service_api(app=client.app) @@ -80,8 +84,9 @@ async def test_invitation_service_unavailable( with pytest.raises(InvitationsServiceUnavailable): await validate_invitation_url( app=client.app, - guest_email=expected_invitation.guest, + guest_email=fake_osparc_invitation.guest, invitation_url="https://server.com#/registration?invitation=1234", + current_product=current_product, ) @@ -99,53 +104,75 @@ async def test_invitation_service_api_ping( # second request is mocked to fail (since mock only works once) assert not await invitations_api.is_responsive() + mock_invitations_service_http_api.assert_called() + async def test_valid_invitation( client: TestClient, mock_invitations_service_http_api: AioResponsesMock, - expected_invitation: ApiInvitationContent, + fake_osparc_invitation: ApiInvitationContent, + current_product: Product, ): assert client.app invitation = await validate_invitation_url( app=client.app, - guest_email=expected_invitation.guest, + guest_email=fake_osparc_invitation.guest, invitation_url="https://server.com#register?invitation=1234", + current_product=current_product, ) assert invitation - assert invitation == expected_invitation + assert invitation == fake_osparc_invitation + mock_invitations_service_http_api.assert_called_once() -async def test_invalid_invitation_if_guest_is_already_registered( +async def test_invalid_invitation_if_guest_is_already_registered_in_product( client: TestClient, mock_invitations_service_http_api: AioResponsesMock, - expected_invitation: ApiInvitationContent, + fake_osparc_invitation: ApiInvitationContent, + current_product: Product, ): assert client.app + kwargs = { + "app": client.app, + "guest_email": fake_osparc_invitation.guest, + "invitation_url": "https://server.com#register?invitation=1234", + "current_product": current_product, + } + + # user exists async with NewUser( params={ "name": "test-user", - "email": expected_invitation.guest, + "email": fake_osparc_invitation.guest, }, app=client.app, - ): + ) as user: + + # valid because user is not assigned to product + assert await validate_invitation_url(**kwargs) == fake_osparc_invitation + + # now use is in product + await auto_add_user_to_product_group( + client.app, user_id=user["id"], product_name=current_product.name + ) + + # invitation is invalid now with pytest.raises(InvalidInvitation): - await validate_invitation_url( - app=client.app, - guest_email=expected_invitation.guest, - invitation_url="https://server.com#register?invitation=1234", - ) + await validate_invitation_url(**kwargs) async def test_invalid_invitation_if_not_guest( client: TestClient, mock_invitations_service_http_api: AioResponsesMock, - expected_invitation: ApiInvitationContent, + fake_osparc_invitation: ApiInvitationContent, + current_product: Product, ): assert client.app - assert expected_invitation.guest != "unexpected_guest@email.me" + assert fake_osparc_invitation.guest != "unexpected_guest@email.me" with pytest.raises(InvalidInvitation): await validate_invitation_url( app=client.app, guest_email="unexpected_guest@email.me", invitation_url="https://server.com#register?invitation=1234", + current_product=current_product, ) diff --git a/services/web/server/tests/unit/with_dbs/03/invitations/test_login_registration_invitations.py b/services/web/server/tests/unit/with_dbs/03/invitations/test_login_registration_invitations.py index 17eb63c1153..60f24d39510 100644 --- a/services/web/server/tests/unit/with_dbs/03/invitations/test_login_registration_invitations.py +++ b/services/web/server/tests/unit/with_dbs/03/invitations/test_login_registration_invitations.py @@ -100,7 +100,7 @@ async def test_check_registration_invitation_and_get_email( client: TestClient, mocker: MockerFixture, mock_invitations_service_http_api: AioResponsesMock, - expected_invitation: ApiInvitationContent, + fake_osparc_invitation: ApiInvitationContent, ): assert client.app mocker.patch( @@ -118,4 +118,4 @@ async def test_check_registration_invitation_and_get_email( data, _ = await assert_status(response, web.HTTPOk) invitation = InvitationInfo.parse_obj(data) - assert invitation.email == expected_invitation.guest + assert invitation.email == fake_osparc_invitation.guest