diff --git a/.vscode/launch.template.json b/.vscode/launch.template.json index 5120843654d..4abbb153965 100644 --- a/.vscode/launch.template.json +++ b/.vscode/launch.template.json @@ -107,6 +107,19 @@ } ] }, + { + "name": "Python: Remote Attach webserver-garbage-collector", + "type": "python", + "request": "attach", + "port": 3011, + "host": "127.0.0.1", + "pathMappings": [ + { + "localRoot": "${workspaceFolder}", + "remoteRoot": "/devel" + } + ] + }, { "name": "Python: Remote Attach storage", "type": "python", diff --git a/scripts/docker/docker-compose-config.bash b/scripts/docker/docker-compose-config.bash index c8231d0f2e5..1e8b5d5e261 100755 --- a/scripts/docker/docker-compose-config.bash +++ b/scripts/docker/docker-compose-config.bash @@ -89,7 +89,7 @@ docker-compose \ --env-file ${env_file}" for compose_file_path in "$@" do - docker_command+=" --file=${compose_file_path}" + docker_command+=" --file=${compose_file_path} " done docker_command+=" \ config \ diff --git a/services/storage/src/simcore_service_storage/exceptions.py b/services/storage/src/simcore_service_storage/exceptions.py index e65684ce238..42ca7c36eff 100644 --- a/services/storage/src/simcore_service_storage/exceptions.py +++ b/services/storage/src/simcore_service_storage/exceptions.py @@ -17,12 +17,14 @@ class FileMetaDataNotFoundError(DatabaseAccessError): class FileAccessRightError(DatabaseAccessError): code = "file.access_right_error" - msg_template: str = "Insufficient access rights to {access_right} {file_id}" + msg_template: str = "Insufficient access rights to {access_right} data {file_id}" class ProjectAccessRightError(DatabaseAccessError): code = "file.access_right_error" - msg_template: str = "Insufficient access rights to {access_right} {project_id}" + msg_template: str = ( + "Insufficient access rights to {access_right} project {project_id}" + ) class ProjectNotFoundError(DatabaseAccessError): 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 a96c58a58fa..592cc3aaca6 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 @@ -150,7 +150,7 @@ async def register(request: web.Request): await check_other_registrations(email=registration.email, db=db, cfg=cfg) - expires_at = None # = does not expire + expires_at: Optional[datetime] = None # = does not expire if settings.LOGIN_REGISTRATION_INVITATION_REQUIRED: # Only requests with INVITATION can register user # to either a permanent or to a trial account diff --git a/services/web/server/src/simcore_service_webserver/projects/projects_db.py b/services/web/server/src/simcore_service_webserver/projects/projects_db.py index ef9cecb866b..5a26cb7e254 100644 --- a/services/web/server/src/simcore_service_webserver/projects/projects_db.py +++ b/services/web/server/src/simcore_service_webserver/projects/projects_db.py @@ -35,8 +35,9 @@ from ..utils import now_str from .project_models import ProjectDict from .projects_db_utils import ( + ANY_USER_ID_SENTINEL, BaseProjectDB, - Permission, + PermissionStr, ProjectAccessRights, assemble_array_groups, check_project_permissions, @@ -55,7 +56,7 @@ log = logging.getLogger(__name__) APP_PROJECT_DBAPI = __name__ + ".ProjectDBAPI" - +ANY_USER = ANY_USER_ID_SENTINEL # pylint: disable=too-many-public-methods # NOTE: https://github.com/ITISFoundation/osparc-simcore/issues/3516 @@ -316,7 +317,7 @@ async def get_project( *, only_published: bool = False, only_templates: bool = False, - check_permissions: Permission = "read", + check_permissions: PermissionStr = "read", ) -> tuple[ProjectDict, ProjectType]: """Returns all projects *owned* by the user @@ -325,6 +326,7 @@ async def get_project( - Notice that a user can have access to a project where he/she has read access :raises ProjectNotFoundError: project is not assigned to user + raises ProjectInvalidRightsError: if user has no access rights to do check_permissions """ async with self.engine.acquire() as conn: project = await self._get_project( @@ -634,7 +636,7 @@ async def list_node_ids_in_project(self, project_uuid: str) -> set[str]: # async def has_permission( - self, user_id: UserID, project_uuid: str, permission: Permission + self, user_id: UserID, project_uuid: str, permission: PermissionStr ) -> bool: """ NOTE: this function should never raise diff --git a/services/web/server/src/simcore_service_webserver/projects/projects_db_utils.py b/services/web/server/src/simcore_service_webserver/projects/projects_db_utils.py index 7bdf25276a0..a9d2ed47dfb 100644 --- a/services/web/server/src/simcore_service_webserver/projects/projects_db_utils.py +++ b/services/web/server/src/simcore_service_webserver/projects/projects_db_utils.py @@ -1,4 +1,4 @@ -""" Database utisl +""" Database utils """ import asyncio @@ -32,7 +32,9 @@ DB_EXCLUSIVE_COLUMNS = ["type", "id", "published", "hidden"] SCHEMA_NON_NULL_KEYS = ["thumbnail"] -Permission = Literal["read", "write", "delete"] +PermissionStr = Literal["read", "write", "delete"] + +ANY_USER_ID_SENTINEL = -1 class ProjectAccessRights(Enum): @@ -46,51 +48,75 @@ def check_project_permissions( project: Union[ProjectProxy, ProjectDict], user_id: int, user_groups: list[RowProxy], - permission: Permission, + permission: str, ) -> None: + """ + :raises ProjectInvalidRightsError if check fails + """ + if not permission: return - needed_permissions = permission.split("|") + operations_on_project = set(permission.split("|")) + assert set(operations_on_project).issubset(set(PermissionStr.__args__)) # nosec - # compute access rights by order of priority all group > organizations > primary - primary_group = next( - filter(lambda x: x.get("type") == GroupType.PRIMARY, user_groups), None - ) - standard_groups = filter(lambda x: x.get("type") == GroupType.STANDARD, user_groups) + # + # Get primary_gid, standard_gids and everyone_gid for user_id + # all_group = next( filter(lambda x: x.get("type") == GroupType.EVERYONE, user_groups), None ) - if primary_group is None or all_group is None: - # the user groups is missing entries + if all_group is None: raise ProjectInvalidRightsError(user_id, project.get("uuid")) + everyone_gid = str(all_group["gid"]) + + if user_id == ANY_USER_ID_SENTINEL: + primary_gid = None + standard_gids = [] + + else: + primary_group = next( + filter(lambda x: x.get("type") == GroupType.PRIMARY, user_groups), None + ) + if primary_group is None: + # the user groups is missing entries + raise ProjectInvalidRightsError(user_id, project.get("uuid")) + + standard_groups = filter( + lambda x: x.get("type") == GroupType.STANDARD, user_groups + ) + + primary_gid = str(primary_group["gid"]) + standard_gids = [str(group["gid"]) for group in standard_groups] + + # + # Composes access rights by order of priority all group > organizations > primary + # project_access_rights = deepcopy(project.get("access_rights", {})) - # compute access rights - no_access_rights = {"read": False, "write": False, "delete": False} - computed_permissions = project_access_rights.get( - str(all_group["gid"]), no_access_rights + # access rights for everyone + user_can = project_access_rights.get( + everyone_gid, {"read": False, "write": False, "delete": False} ) - # get the standard groups - for group in standard_groups: + # access rights for standard groups + for group_id in standard_gids: standard_project_access = project_access_rights.get( - str(group["gid"]), no_access_rights + group_id, {"read": False, "write": False, "delete": False} ) - for k in computed_permissions.keys(): - computed_permissions[k] = ( - computed_permissions[k] or standard_project_access[k] + for operation in user_can.keys(): + user_can[operation] = ( + user_can[operation] or standard_project_access[operation] ) - - # get the primary group access + # access rights for primary group primary_access_right = project_access_rights.get( - str(primary_group["gid"]), no_access_rights + primary_gid, {"read": False, "write": False, "delete": False} ) - for k in computed_permissions.keys(): - computed_permissions[k] = computed_permissions[k] or primary_access_right[k] + for operation in user_can.keys(): + user_can[operation] = user_can[operation] or primary_access_right[operation] - if any(not computed_permissions[p] for p in needed_permissions): + if any(not user_can[operation] for operation in operations_on_project): raise ProjectInvalidRightsError(user_id, project.get("uuid")) @@ -146,16 +172,31 @@ def assemble_array_groups(user_groups: list[RowProxy]) -> str: class BaseProjectDB: - @staticmethod - async def _list_user_groups(conn: SAConnection, user_id: int) -> list[RowProxy]: - user_groups: list[RowProxy] = [] - query = ( - select([groups]) - .select_from(groups.join(user_to_groups)) - .where(user_to_groups.c.uid == user_id) + @classmethod + async def _get_everyone_group(cls, conn: SAConnection) -> RowProxy: + result = await conn.execute( + sa.select([groups]).where(groups.c.type == GroupType.EVERYONE) ) - async for row in conn.execute(query): - user_groups.append(row) + row = await result.first() + return row + + @classmethod + async def _list_user_groups( + cls, conn: SAConnection, user_id: int + ) -> list[RowProxy]: + user_groups: list[RowProxy] = [] + + if user_id == ANY_USER_ID_SENTINEL: + everyone_group = await cls._get_everyone_group(conn) + assert everyone_group # nosec + user_groups.append(everyone_group) + else: + result = await conn.execute( + select([groups]) + .select_from(groups.join(user_to_groups)) + .where(user_to_groups.c.uid == user_id) + ) + user_groups = await result.fetchall() return user_groups @staticmethod @@ -268,10 +309,11 @@ async def _get_project( for_update: bool = False, only_templates: bool = False, only_published: bool = False, - check_permissions: Permission = "read", + check_permissions: PermissionStr = "read", ) -> dict: """ - raises: ProjectNotFoundError + raises ProjectNotFoundError if project does not exists + raises ProjectInvalidRightsError if user_id does not have at 'check_permissions' access rights """ exclude_foreign = exclude_foreign or [] @@ -308,14 +350,14 @@ async def _get_project( project_row = await result.first() if not project_row: - raise ProjectNotFoundError(project_uuid) - - # now carefuly check the access rights - if only_published is False: - check_project_permissions( - project_row, user_id, user_groups, check_permissions + raise ProjectNotFoundError( + project_uuid=project_uuid, + search_context=f"{user_id=}, {only_templates=}, {only_published=}, {check_permissions=}", ) + # check the access rights + check_project_permissions(project_row, user_id, user_groups, check_permissions) + project: dict[str, Any] = dict(project_row.items()) if "tags" not in exclude_foreign: diff --git a/services/web/server/src/simcore_service_webserver/projects/projects_exceptions.py b/services/web/server/src/simcore_service_webserver/projects/projects_exceptions.py index 1143dabd961..7abfe38ede2 100644 --- a/services/web/server/src/simcore_service_webserver/projects/projects_exceptions.py +++ b/services/web/server/src/simcore_service_webserver/projects/projects_exceptions.py @@ -1,4 +1,6 @@ """Defines the different exceptions that may arise in the projects subpackage""" +from typing import Any, Optional + import redis.exceptions from models_library.projects import ProjectID from models_library.users import UserID @@ -10,6 +12,10 @@ class ProjectsException(Exception): def __init__(self, msg=None): super().__init__(msg or "Unexpected error occured in projects submodule") + def detailed_message(self): + # Override in subclass + return f"{type(self)}: {self}" + class ProjectInvalidRightsError(ProjectsException): """Invalid rights to access project""" @@ -33,9 +39,17 @@ def __init__(self, project_uuid): class ProjectNotFoundError(ProjectsException): """Project was not found in DB""" - def __init__(self, project_uuid): - super().__init__(f"Project with uuid {project_uuid} not found") + def __init__(self, project_uuid, *, search_context: Optional[Any] = None): + super().__init__(f"Project with uuid {project_uuid} not found.") self.project_uuid = project_uuid + self.search_context_msg = f"{search_context}" + + def detailed_message(self): + msg = f"Project with uuid {self.project_uuid}" + if self.search_context_msg: + msg += f" and {self.search_context_msg}" + msg += " was not found" + return msg class ProjectDeleteError(ProjectsException): diff --git a/services/web/server/src/simcore_service_webserver/studies_dispatcher/_constants.py b/services/web/server/src/simcore_service_webserver/studies_dispatcher/_constants.py new file mode 100644 index 00000000000..2e714572457 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/studies_dispatcher/_constants.py @@ -0,0 +1,15 @@ +from typing import Final + +# NOTE: MSG_* strings MUST be human readable messages + +MSG_PROJECT_NOT_FOUND: Final[str] = "Cannot find any study with ID '{project_id}'." + + +MSG_PROJECT_NOT_PUBLISHED: Final[ + str +] = "Cannot find any published study with ID '{project_id}'" + + +MSG_UNEXPECTED_ERROR: Final[ + str +] = "Opps this is embarrasing! Something went really wrong {hint}" diff --git a/services/web/server/src/simcore_service_webserver/studies_dispatcher/_projects.py b/services/web/server/src/simcore_service_webserver/studies_dispatcher/_projects.py index 21d2468c073..e5490666b0a 100644 --- a/services/web/server/src/simcore_service_webserver/studies_dispatcher/_projects.py +++ b/services/web/server/src/simcore_service_webserver/studies_dispatcher/_projects.py @@ -159,7 +159,7 @@ async def add_new_project( app: web.Application, project: Project, user: UserInfo, *, product_name: str ): # TODO: move this to projects_api - # TODO: this piece was taking fromt the end of projects.projects_handlers.create_projects + # TODO: this piece was taken from the end of projects.projects_handlers.create_projects from ..director_v2_api import create_or_update_pipeline from ..projects.projects_db import APP_PROJECT_DBAPI diff --git a/services/web/server/src/simcore_service_webserver/studies_dispatcher/_studies_access.py b/services/web/server/src/simcore_service_webserver/studies_dispatcher/_studies_access.py index 329167a41f5..6472fe788b4 100644 --- a/services/web/server/src/simcore_service_webserver/studies_dispatcher/_studies_access.py +++ b/services/web/server/src/simcore_service_webserver/studies_dispatcher/_studies_access.py @@ -8,70 +8,113 @@ - access to security - access to login -TODO: Refactor to reduce modules coupling! See all TODO: .``from ...`` comments + +NOTE: Some of the code below is duplicated in the studies_dispatcher! +SEE refactoring plan in https://github.com/ITISFoundation/osparc-simcore/issues/3977 """ +import functools import logging +from datetime import datetime from functools import lru_cache from uuid import UUID, uuid5 import redis.asyncio as aioredis from aiohttp import web from aiohttp_session import get_session +from servicelib.aiohttp.typing_extension import Handler from servicelib.error_codes import create_error_code +from simcore_service_webserver.projects.project_models import ProjectDict from .._constants import INDEX_RESOURCE_NAME from ..garbage_collector_settings import GUEST_USER_RC_LOCK_FORMAT from ..products import get_product_name -from ..projects.projects_db import ProjectDBAPI -from ..projects.projects_exceptions import ProjectNotFoundError +from ..projects.projects_db import ANY_USER, ProjectDBAPI +from ..projects.projects_exceptions import ( + ProjectInvalidRightsError, + ProjectNotFoundError, +) from ..redis import get_redis_lock_manager_client from ..security_api import is_anonymous, remember from ..storage_api import copy_data_folders_from_project from ..utils import compose_support_error_msg +from ..utils_aiohttp import create_redirect_response +from ._constants import ( + MSG_PROJECT_NOT_FOUND, + MSG_PROJECT_NOT_PUBLISHED, + MSG_UNEXPECTED_ERROR, +) +from .settings import StudiesDispatcherSettings, get_plugin_settings log = logging.getLogger(__name__) -# TODO: Integrate this in studies_dispatcher -BASE_UUID = UUID("71e0eb5e-0797-4469-89ba-00a0df4d338a") +_BASE_UUID = UUID("71e0eb5e-0797-4469-89ba-00a0df4d338a") @lru_cache -def compose_uuid(template_uuid, user_id, query="") -> str: +def _compose_uuid(template_uuid, user_id, query="") -> str: """Creates a new uuid composing a project's and user ids such that any template pre-assigned to a user Enforces a constraint: a user CANNOT have multiple copies of the same template """ - new_uuid = str(uuid5(BASE_UUID, str(template_uuid) + str(user_id) + str(query))) + new_uuid = str(uuid5(_BASE_UUID, str(template_uuid) + str(user_id) + str(query))) return new_uuid -async def get_public_project(app: web.Application, project_uuid: str): +async def _get_published_template_project( + app: web.Application, project_uuid: str +) -> ProjectDict: """ - Returns project if project_uuid is a template and is marked as published, otherwise None + raises RedirectToFrontEndPageError """ db = ProjectDBAPI.get_from_app_context(app) - prj, _ = await db.get_project( - -1, project_uuid, only_published=True, only_templates=True - ) - return prj + try: + prj, _ = await db.get_project( + project_uuid=project_uuid, + # NOTE: these are the conditions for a published study + # 1. MUST be a template + only_templates=True, + # 2. MUST be checked for publication + only_published=True, + # 3. MUST be shared with EVERYONE=1 in read mode, i.e. + user_id=ANY_USER, # any user + check_permissions="read", # any user has read access + ) + if not prj: + # Not sure this happens but this condition was checked before so better be safe + raise ProjectNotFoundError(project_uuid) -async def create_temporary_user(request: web.Request): - """ - TODO: user should have an expiration date and limited persmissions! - """ + return prj + + except (ProjectNotFoundError, ProjectInvalidRightsError) as err: + log.debug( + "Requested project with %s is not published. Reason: %s", + f"{project_uuid=}", + err.detailed_message(), + ) + + raise RedirectToFrontEndPageError( + MSG_PROJECT_NOT_PUBLISHED.format(project_id=project_uuid), + error_code="PROJECT_NOT_PUBLISHED", + status_code=web.HTTPNotFound.status_code, + ) from err + + +async def _create_temporary_user(request: web.Request): from ..login.storage import AsyncpgStorage, get_plugin_storage from ..login.utils import ACTIVE, GUEST, get_client_ip, get_random_string from ..security_api import encrypt_password db: AsyncpgStorage = get_plugin_storage(request.app) redis_locks_client: aioredis.Redis = get_redis_lock_manager_client(request.app) + settings: StudiesDispatcherSettings = get_plugin_settings(app=request.app) - # TODO: avatar is an icon of the hero! + # Profile for temporary user random_uname = get_random_string(min_len=5) email = random_uname + "@guest-at-osparc.io" password = get_random_string(min_len=12) + expires_at = datetime.utcnow() + settings.STUDIES_GUEST_ACCOUNT_LIFETIME # GUEST_USER_RC_LOCK: # @@ -112,6 +155,7 @@ async def create_temporary_user(request: web.Request): "status": ACTIVE, "role": GUEST, "created_ip": get_client_ip(request), + "expires_at": expires_at, } ) # (2) read details above @@ -123,7 +167,6 @@ async def create_temporary_user(request: web.Request): return user -# TODO: from .users import get_user? async def get_authorized_user(request: web.Request) -> dict: from ..login.storage import AsyncpgStorage, get_plugin_storage from ..security_api import authorized_userid @@ -134,7 +177,6 @@ async def get_authorized_user(request: web.Request) -> dict: return user -# TODO: from .projects import ...? async def copy_study_to_account( request: web.Request, template_project: dict, user: dict ): @@ -150,13 +192,11 @@ async def copy_study_to_account( substitute_parameterized_inputs, ) - # FIXME: ONLY projects should have access to db since it avoids access layer - # TODO: move to project_api and add access layer db: ProjectDBAPI = request.config_dict[APP_PROJECT_DBAPI] template_parameters = dict(request.query) - # assign id to copy - project_uuid = compose_uuid( + # assign new uuid to copy + project_uuid = _compose_uuid( template_project["uuid"], user["id"], str(template_parameters) ) @@ -164,8 +204,6 @@ async def copy_study_to_account( # Avoids multiple copies of the same template on each account await db.get_project(user["id"], project_uuid) - # FIXME: if template is parametrized and user has already a copy, then delete it and create a new one?? - except ProjectNotFoundError: # New project cloned from template project, nodes_map = clone_project_document( @@ -211,6 +249,62 @@ async def copy_study_to_account( # HANDLERS -------------------------------------------------------- + + +class RedirectToFrontEndPageError(Exception): + def __init__( + self, human_readable_message: str, error_code: str, status_code: int + ) -> None: + self.human_readable_message = human_readable_message + self.error_code = error_code + self.status_code = status_code + super().__init__(human_readable_message) + + +def _handle_errors_with_error_page(handler: Handler): + @functools.wraps(handler) + async def wrapper(request: web.Request) -> web.StreamResponse: + try: + return await handler(request) + + except ProjectNotFoundError as err: + raise create_redirect_response( + request.app, + page="error", + message=compose_support_error_msg( + msg=MSG_PROJECT_NOT_FOUND.format(project_id=err.project_uuid), + error_code="PROJECT_NOT_FOUND", + ), + status_code=web.HTTPNotFound.status_code, + ) from err + + except RedirectToFrontEndPageError as err: + raise create_redirect_response( + request.app, + page="error", + message=compose_support_error_msg( + msg=err.human_readable_message, error_code=err.error_code + ), + status_code=err.status_code, + ) from err + + except Exception as err: + error_code = create_error_code(err) + log.exception( + "Unexpected failure while dispatching study [%s]", + f"{error_code}", + extra={"error_code": error_code}, + ) + raise RedirectToFrontEndPageError( + MSG_UNEXPECTED_ERROR.format(hint=""), + error_code=error_code, + status_code=web.HTTPInternalServerError.status_code, + ) from err + + return wrapper + + +@_handle_errors_with_error_page async def get_redirection_to_study_page(request: web.Request) -> web.Response: """ Handles requests to get and open a public study @@ -219,47 +313,25 @@ async def get_redirection_to_study_page(request: web.Request) -> web.Response: - if user is not registered, it creates a temporary guest account with limited resources and expiration - this handler is NOT part of the API and therefore does NOT respond with json """ - # TODO: implement nice error-page.html project_id = request.match_info["id"] + assert request.app.router[INDEX_RESOURCE_NAME] # nosec - try: - template_project = await get_public_project(request.app, project_id) - except ProjectNotFoundError as exc: - raise web.HTTPNotFound( - reason=f"Requested study ({project_id}) has not been published.\ - Please contact the data curators for more information." - ) from exc - if not template_project: - raise web.HTTPNotFound( - reason=f"Requested study ({project_id}) has not been published.\ - Please contact the data curators for more information." - ) + # Get published PROJECT referenced in link + template_project = await _get_published_template_project(request.app, project_id) - # Get or create a valid user + # Get or create a valid USER user = None is_anonymous_user = await is_anonymous(request) if not is_anonymous_user: # NOTE: covers valid cookie with unauthorized user (e.g. expired guest/banned) - # TODO: test if temp user overrides old cookie properly user = await get_authorized_user(request) - try: - if not user: - log.debug("Creating temporary user ...") - user = await create_temporary_user(request) - is_anonymous_user = True - except Exception as exc: # pylint: disable=broad-except - error_code = create_error_code(exc) - log.exception( - "Failed while creating temporary user. TIP: too many simultaneous request? [%s]", - f"{error_code}", - extra={"error_code": error_code}, - ) - raise web.HTTPInternalServerError( - reason=compose_support_error_msg( - "Unable to create temporary user", error_code - ) - ) from exc + if not user: + log.debug("Creating temporary user ...") + user = await _create_temporary_user(request) + is_anonymous_user = True + + # COPY try: log.debug( "Granted access to study '%s' for user %s. Copying study over ...", @@ -279,27 +351,18 @@ async def get_redirection_to_study_page(request: web.Request) -> web.Response: f"{error_code}", extra={"error_code": error_code}, ) - raise web.HTTPInternalServerError( - reason=compose_support_error_msg("Unable to copy project", error_code) + raise RedirectToFrontEndPageError( + MSG_UNEXPECTED_ERROR.format(hint="while copying your study"), + error_code=error_code, + status_code=web.HTTPInternalServerError.status_code, ) from exc - try: - redirect_url = ( - request.app.router[INDEX_RESOURCE_NAME] - .url_for() - .with_fragment(f"/study/{copied_project_id}") - ) - except KeyError as exc: - error_code = create_error_code(exc) - log.exception( - "Cannot redirect to website because route was not registered. " - "Probably the static-webserver is disabled (see statics.py) [%s]", - f"{error_code}", - extra={"error_code": error_code}, - ) - raise web.HTTPInternalServerError( - reason=compose_support_error_msg("Unable to serve front-end", error_code) - ) from exc + # Creating REDIRECTION LINK + redirect_url = ( + request.app.router[INDEX_RESOURCE_NAME] + .url_for() + .with_fragment(f"/study/{copied_project_id}") + ) response = web.HTTPFound(location=redirect_url) if is_anonymous_user: @@ -308,10 +371,8 @@ async def get_redirection_to_study_page(request: web.Request) -> web.Response: await remember(request, response, identity) - assert (await get_session(request))["AIOHTTP_SECURITY"] == identity # nosec - # NOTE: session is encrypted and stored in a cookie in the session middleware + assert (await get_session(request))["AIOHTTP_SECURITY"] == identity # nosec # WARNING: do NOT raise this response. From aiohttp 3.7.X, response is rebuild and cookie ignore. - # TODO: PC: security with SessionIdentityPolicy, session with EncryptedCookieStorage -> remember() and raise response. return response diff --git a/services/web/server/src/simcore_service_webserver/studies_dispatcher/_users.py b/services/web/server/src/simcore_service_webserver/studies_dispatcher/_users.py index 3a9252a8a7f..6f9f93346a0 100644 --- a/services/web/server/src/simcore_service_webserver/studies_dispatcher/_users.py +++ b/services/web/server/src/simcore_service_webserver/studies_dispatcher/_users.py @@ -8,6 +8,7 @@ """ import logging +from datetime import datetime from typing import Optional import redis.asyncio as aioredis @@ -21,6 +22,7 @@ from ..security_api import authorized_userid, encrypt_password, is_anonymous, remember from ..users_api import get_user from ..users_exceptions import UserNotFoundError +from .settings import StudiesDispatcherSettings, get_plugin_settings log = logging.getLogger(__name__) @@ -50,11 +52,12 @@ async def _get_authorized_user(request: web.Request) -> Optional[dict]: async def _create_temporary_user(request: web.Request): db: AsyncpgStorage = get_plugin_storage(request.app) redis_locks_client: aioredis.Redis = get_redis_lock_manager_client(request.app) + settings: StudiesDispatcherSettings = get_plugin_settings(app=request.app) - # TODO: avatar is an icon of the hero! random_user_name = get_random_string(min_len=5) email = random_user_name + "@guest-at-osparc.io" password = get_random_string(min_len=12) + expires_at = datetime.utcnow() + settings.STUDIES_GUEST_ACCOUNT_LIFETIME # GUEST_USER_RC_LOCK: # @@ -96,6 +99,7 @@ async def _create_temporary_user(request: web.Request): "status": ACTIVE, "role": GUEST, "created_ip": get_client_ip(request), + "expires_at": expires_at, } ) user: dict = await get_user(request.app, usr["id"]) diff --git a/services/web/server/src/simcore_service_webserver/studies_dispatcher/settings.py b/services/web/server/src/simcore_service_webserver/studies_dispatcher/settings.py index 4ff7e35f95a..846e8661218 100644 --- a/services/web/server/src/simcore_service_webserver/studies_dispatcher/settings.py +++ b/services/web/server/src/simcore_service_webserver/studies_dispatcher/settings.py @@ -1,4 +1,7 @@ +from datetime import timedelta + from aiohttp import web +from pydantic import validator from pydantic.fields import Field from servicelib.aiohttp.application_keys import APP_SETTINGS_KEY from settings_library.base import BaseCustomSettings @@ -11,9 +14,34 @@ class StudiesDispatcherSettings(BaseCustomSettings): env=["STUDIES_ACCESS_ANONYMOUS_ALLOWED", "WEBSERVER_STUDIES_ACCESS_ENABLED"], ) + STUDIES_GUEST_ACCOUNT_LIFETIME: timedelta = Field( + default=timedelta(minutes=15), + description="Sets lifetime of a guest user until it is logged out " + " and removed by the GC", + ) + + @validator("STUDIES_GUEST_ACCOUNT_LIFETIME") + @classmethod + def is_positive_lifetime(cls, v): + if v and isinstance(v, timedelta) and v.total_seconds() <= 0: + raise ValueError(f"Must be a positive number, got {v.total_seconds()=}") + return v + def is_login_required(self): + """Returns False if study access entrypoint does not require auth + + NOTE: in special cases this entrypoing can be programatically protected with auth + """ return not self.STUDIES_ACCESS_ANONYMOUS_ALLOWED + class Config: + schema_extra = { + "example": { + "STUDIES_GUEST_ACCOUNT_LIFETIME": "2 1:10:00", # 2 days 1h and 10 mins + "STUDIES_ACCESS_ANONYMOUS_ALLOWED": "1", + }, + } + def get_plugin_settings(app: web.Application) -> StudiesDispatcherSettings: settings = app[APP_SETTINGS_KEY].WEBSERVER_STUDIES_DISPATCHER diff --git a/services/web/server/tests/conftest.py b/services/web/server/tests/conftest.py index 064eb980c3f..95dd3383264 100644 --- a/services/web/server/tests/conftest.py +++ b/services/web/server/tests/conftest.py @@ -109,6 +109,7 @@ def fake_data_dir(tests_data_dir: Path) -> Path: @pytest.fixture def fake_project(tests_data_dir: Path) -> ProjectDict: + """fake data for a project in a response body of GET /project/{uuid} (see tests/data/fake-project.json)""" # TODO: rename as fake_project_data since it does not produce a BaseModel but its **data fpath = tests_data_dir / "fake-project.json" assert fpath.exists() diff --git a/services/web/server/tests/integration/01/test_project_workflow.py b/services/web/server/tests/integration/01/test_project_workflow.py index 8c867e5f833..77291c4db38 100644 --- a/services/web/server/tests/integration/01/test_project_workflow.py +++ b/services/web/server/tests/integration/01/test_project_workflow.py @@ -13,7 +13,7 @@ import asyncio from copy import deepcopy -from typing import Awaitable, Callable, Optional, Union +from typing import Awaitable, Callable from uuid import uuid4 import pytest @@ -142,10 +142,12 @@ async def _mock_result(): @pytest.fixture -async def catalog_subsystem_mock(monkeypatch): +async def catalog_subsystem_mock( + monkeypatch: pytest.MonkeyPatch, +) -> Callable[[list[ProjectDict]], None]: services_in_project = [] - def creator(projects: Optional[Union[list[dict], dict]] = None) -> None: + def _creator(projects: list[ProjectDict]) -> None: for proj in projects: services_in_project.extend( [ @@ -161,7 +163,7 @@ async def mocked_get_services_for_user(*args, **kwargs): catalog, "get_services_for_user_in_product", mocked_get_services_for_user ) - return creator + return _creator # Tests CRUD operations -------------------------------------------- @@ -210,7 +212,7 @@ async def test_workflow( docker_registry: str, simcore_services_ready, fake_project: ProjectDict, - catalog_subsystem_mock, + catalog_subsystem_mock: Callable[[list[ProjectDict]], None], client, logged_user, primary_group: dict[str, str], diff --git a/services/web/server/tests/unit/isolated/test_studies_dispatcher_settings.py b/services/web/server/tests/unit/isolated/test_studies_dispatcher_settings.py new file mode 100644 index 00000000000..5ef47cb0aee --- /dev/null +++ b/services/web/server/tests/unit/isolated/test_studies_dispatcher_settings.py @@ -0,0 +1,58 @@ +# pylint: disable=redefined-outer-name +# pylint: disable=unused-argument +# pylint: disable=unused-variable +# pylint: disable=too-many-arguments + +from datetime import timedelta + +import pytest +from models_library.errors import ErrorDict +from pydantic import ValidationError +from pytest_simcore.helpers.utils_envs import setenvs_from_dict +from simcore_service_webserver.socketio.handlers_utils import EnvironDict +from simcore_service_webserver.studies_dispatcher.settings import ( + StudiesDispatcherSettings, +) + + +@pytest.fixture +def environment(monkeypatch: pytest.MonkeyPatch) -> EnvironDict: + envs = setenvs_from_dict( + monkeypatch, + envs=StudiesDispatcherSettings.Config.schema_extra["example"], + ) + + return envs + + +def test_studies_dispatcher_settings(environment: EnvironDict): + + settings = StudiesDispatcherSettings.create_from_envs() + + assert environment == { + "STUDIES_GUEST_ACCOUNT_LIFETIME": "2 1:10:00", + "STUDIES_ACCESS_ANONYMOUS_ALLOWED": "1", + } + + assert not settings.is_login_required() + + # 2 days 1h and 10 mins + assert settings.STUDIES_GUEST_ACCOUNT_LIFETIME == timedelta( + days=2, hours=1, minutes=10 + ) + + +def test_studies_dispatcher_settings_invalid_lifetime( + environment: EnvironDict, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.setenv("STUDIES_GUEST_ACCOUNT_LIFETIME", "-2") + + with pytest.raises(ValidationError) as exc_info: + StudiesDispatcherSettings.create_from_envs() + + validation_error: ErrorDict = exc_info.value.errors()[0] + assert "-2" in validation_error.pop("msg", "") + assert validation_error == { + "loc": ("STUDIES_GUEST_ACCOUNT_LIFETIME",), + "type": "value_error", + } diff --git a/services/web/server/tests/unit/with_dbs/01/test_studies_dispatcher_handlers.py b/services/web/server/tests/unit/with_dbs/01/test_studies_dispatcher_handlers.py index 9cc1da54b07..0050fb68263 100644 --- a/services/web/server/tests/unit/with_dbs/01/test_studies_dispatcher_handlers.py +++ b/services/web/server/tests/unit/with_dbs/01/test_studies_dispatcher_handlers.py @@ -14,6 +14,7 @@ from aiohttp import ClientResponse, ClientSession, web from aioresponses import aioresponses from models_library.projects_state import ProjectLocked, ProjectStatus +from pytest import MonkeyPatch from pytest_simcore.helpers.utils_assert import assert_status from pytest_simcore.helpers.utils_login import UserRole from simcore_service_webserver import catalog @@ -259,7 +260,7 @@ async def test_api_list_supported_filetypes(client): @pytest.fixture -async def catalog_subsystem_mock(monkeypatch): +async def catalog_subsystem_mock(monkeypatch: MonkeyPatch) -> None: services_in_project = [ {"key": "simcore/services/frontend/file-picker", "version": "1.0.0"} @@ -324,7 +325,7 @@ async def assert_redirected_to_study( async def test_dispatch_viewer_anonymously( client, storage_subsystem_mock, - catalog_subsystem_mock, + catalog_subsystem_mock: None, mocks_on_projects_api, mocker, ): diff --git a/services/web/server/tests/unit/with_dbs/01/test_studies_dispatcher_studies_access.py b/services/web/server/tests/unit/with_dbs/01/test_studies_dispatcher_studies_access.py index 961659288c7..987cb522b38 100644 --- a/services/web/server/tests/unit/with_dbs/01/test_studies_dispatcher_studies_access.py +++ b/services/web/server/tests/unit/with_dbs/01/test_studies_dispatcher_studies_access.py @@ -1,95 +1,123 @@ -""" Covers user stories for ISAN : #501, #712, #730 - -""" -# pylint:disable=unused-variable -# pylint:disable=unused-argument -# pylint:disable=redefined-outer-name +# pylint: disable=redefined-outer-name +# pylint: disable=unused-argument +# pylint: disable=unused-variable +# pylint: disable=too-many-arguments import asyncio import logging import re +import urllib.parse from copy import deepcopy from pathlib import Path from pprint import pprint from typing import AsyncGenerator, AsyncIterator, Callable import pytest +import redis.asyncio as aioredis from aiohttp import ClientResponse, ClientSession, web -from aiohttp.test_utils import TestClient -from aioresponses import aioresponses +from aiohttp.test_utils import TestClient, TestServer +from faker import Faker from models_library.projects_state import ProjectLocked, ProjectStatus -from pytest_mock.plugin import MockerFixture +from pytest import MonkeyPatch +from pytest_mock import MockerFixture +from pytest_simcore.aioresponses_mocker import AioResponsesMock +from pytest_simcore.helpers.typing_env import EnvVarsDict from pytest_simcore.helpers.utils_assert import assert_status -from pytest_simcore.helpers.utils_dict import ConfigDict -from pytest_simcore.helpers.utils_login import UserRole +from pytest_simcore.helpers.utils_envs import setenvs_from_dict +from pytest_simcore.helpers.utils_login import UserInfoDict, UserRole from pytest_simcore.helpers.utils_projects import NewProject, delete_all_projects +from pytest_simcore.helpers.utils_webserver_unit_with_db import MockedStorageSubsystem from servicelib.aiohttp.long_running_tasks.client import LRTask from servicelib.aiohttp.long_running_tasks.server import TaskProgress from servicelib.aiohttp.rest_responses import unwrap_envelope -from settings_library.redis import RedisSettings from simcore_service_webserver.log import setup_logging from simcore_service_webserver.projects.project_models import ProjectDict from simcore_service_webserver.projects.projects_api import submit_delete_project_task from simcore_service_webserver.users_api import delete_user, get_user_role -SHARED_STUDY_UUID = "e2e38eee-c569-4e55-b104-70d159e49c87" - @pytest.fixture -def app_cfg( - default_app_cfg: ConfigDict, - unused_tcp_port_factory: Callable, - redis_service: RedisSettings, -): - """App's configuration used for every test in this module +def app_environment(app_environment: EnvVarsDict, monkeypatch: MonkeyPatch): + envs_plugins = setenvs_from_dict( + monkeypatch, + { + "WEBSERVER_ACTIVITY": "null", + "WEBSERVER_CATALOG": "null", + "WEBSERVER_CLUSTERS": "null", + "WEBSERVER_COMPUTATION": "0", + "WEBSERVER_DIAGNOSTICS": "null", + "WEBSERVER_DIRECTOR": "null", + # "WEBSERVER_DIRECTOR_V2": MOCKED + "WEBSERVER_EXPORTER": "null", + # Enforces smallest GC in the background task + # "WEBSERVER_GARBAGE_COLLECTOR": "null", + # cfg["resource_manager"]["garbage_collection_interval_seconds"] = 1 + # "GARBAGE_COLLECTOR_INTERVAL_S": "1", + "WEBSERVER_GROUPS": "1", + "WEBSERVER_META_MODELING": "null", + "WEBSERVER_PRODUCTS": "1", + "WEBSERVER_PUBLICATIONS": "0", + "WEBSERVER_RABBITMQ": "null", + "WEBSERVER_REMOTE_DEBUG": "0", + # "WEBSERVER_STORAGE": MOCKED + "WEBSERVER_SOCKETIO": "0", + "WEBSERVER_TAGS": "1", + "WEBSERVER_TRACING": "null", + "WEBSERVER_USERS": "1", + "WEBSERVER_VERSION_CONTROL": "0", + }, + ) - NOTE: Overrides services/web/server/tests/unit/with_dbs/conftest.py::app_cfg to influence app setup - """ - cfg = deepcopy(default_app_cfg) + monkeypatch.delenv("WEBSERVER_STUDIES_DISPATCHER", raising=False) + app_environment.pop("WEBSERVER_STUDIES_DISPATCHER", None) + + monkeypatch.delenv( + "WEBSERVER_STUDIES_ACCESS_ENABLED", raising=False + ) # legacy for STUDIES_ACCESS_ANONYMOUS_ALLOWED + envs_studies_dispatcher = setenvs_from_dict( + monkeypatch, + { + "STUDIES_ACCESS_ANONYMOUS_ALLOWED": "1", + }, + ) - cfg["main"]["port"] = unused_tcp_port_factory() - cfg["main"]["studies_access_enabled"] = True + # NOTE: To see logs, use pytest -s --log-cli-level=DEBUG + setup_logging(level=logging.DEBUG) - exclude = { - "tracing", - "director", - "smtp", - "storage", - "activity", - "diagnostics", - "groups", - "tags", - "publications", - "catalog", - "computation", - "clusters", - } - include = { - "db", - "rest", - "projects", - "login", - "socketio", - "resource_manager", - "users", - "products", - } + return {**app_environment, **envs_plugins, **envs_studies_dispatcher} - assert include.intersection(exclude) == set() - for section in include: - cfg[section]["enabled"] = True - for section in exclude: - cfg[section]["enabled"] = False +async def _get_user_projects(client): + url = client.app.router["list_projects"].url_for() + resp = await client.get(url.with_query(type="user")) - # NOTE: To see logs, use pytest -s --log-cli-level=DEBUG - setup_logging(level=logging.DEBUG) + payload = await resp.json() + assert resp.status == web.HTTPOk.status_code, payload + + projects, error = unwrap_envelope(payload) + assert not error, pprint(error) + + return projects + + +def _assert_same_projects(got: dict, expected: dict): + exclude = { + "creationDate", + "lastChangeDate", + "prjOwner", + "uuid", + "workbench", + "accessRights", + "ui", + } + for key in expected.keys(): + if key not in exclude: + assert got[key] == expected[key], "Failed in %s" % key - # Enforces smallest GC in the background task - cfg["resource_manager"]["garbage_collection_interval_seconds"] = 1 - return cfg +def _is_user_authenticated(session: ClientSession) -> bool: + return "osparc.WEBAPI_SESSION" in [c.key for c in session.cookie_jar] @pytest.fixture @@ -98,16 +126,22 @@ async def published_project( fake_project: ProjectDict, tests_data_dir: Path, osparc_product_name: str, -) -> AsyncIterator[dict]: +) -> AsyncIterator[ProjectDict]: + project_data = deepcopy(fake_project) project_data["name"] = "Published project" - project_data["uuid"] = SHARED_STUDY_UUID - project_data["published"] = True + project_data["uuid"] = "e2e38eee-c569-4e55-b104-70d159e49c87" + project_data["published"] = True # OPENED + project_data["access_rights"] = { + # everyone HAS read access + "1": {"read": True, "write": False, "delete": False} + } async with NewProject( project_data, client.app, user_id=None, + as_template=True, # <--IS a template product_name=osparc_product_name, clear_all=True, tests_data_dir=tests_data_dir, @@ -121,93 +155,28 @@ async def unpublished_project( fake_project: ProjectDict, tests_data_dir: Path, osparc_product_name: str, -): +) -> ProjectDict: + """An unpublished template""" + project_data = deepcopy(fake_project) - project_data["name"] = "Template Unpublished project" + project_data["name"] = "Unpublished project" project_data["uuid"] = "b134a337-a74f-40ff-a127-b36a1ccbede6" - project_data["published"] = False + project_data["published"] = False # <-- async with NewProject( project_data, client.app, user_id=None, + as_template=True, product_name=osparc_product_name, clear_all=True, tests_data_dir=tests_data_dir, - as_template=True, ) as template_project: yield template_project @pytest.fixture -async def director_v2_mock(director_v2_service_mock) -> AsyncIterator[aioresponses]: - yield director_v2_service_mock - - -async def _get_user_projects(client): - url = client.app.router["list_projects"].url_for() - resp = await client.get(url.with_query(type="user")) - - payload = await resp.json() - assert resp.status == 200, payload - - projects, error = unwrap_envelope(payload) - assert not error, pprint(error) - - return projects - - -def _assert_same_projects(got: dict, expected: dict): - # TODO: validate using api/specs/webserver/v0/components/schemas/project-v0.0.1.json - # TODO: validate workbench! - exclude = { - "creationDate", - "lastChangeDate", - "prjOwner", - "uuid", - "workbench", - "accessRights", - "ui", - } - for key in expected.keys(): - if key not in exclude: - assert got[key] == expected[key], "Failed in %s" % key - - -def is_user_authenticated(session: ClientSession) -> bool: - return "osparc.WEBAPI_SESSION" in [c.key for c in session.cookie_jar] - - -async def assert_redirected_to_study( - resp: ClientResponse, session: ClientSession -) -> str: - - # https://docs.aiohttp.org/en/stable/client_advanced.html#redirection-history - assert len(resp.history) == 1, "Is a re-direction" - - content = await resp.text() - assert resp.status == web.HTTPOk.status_code, f"Got {content}" - - # Expects redirection to osparc web - assert resp.url.path == "/" - assert ( - "OSPARC-SIMCORE" in content - ), "Expected front-end rendering workbench's study, got %s" % str(content) - - # Expects auth cookie for current user - assert is_user_authenticated(session) - - # Expects fragment to indicate client where to find newly created project - m = re.match(r"/study/([\d\w-]+)", resp.real_url.fragment) - assert m, f"Expected /study/uuid, got {resp.real_url.fragment}" - - # returns newly created project - redirected_project_id = m.group(1) - return redirected_project_id - - -@pytest.fixture -def mocks_on_projects_api(mocker) -> None: +def mocks_on_projects_api(mocker: MockerFixture) -> None: """ All projects in this module are UNLOCKED """ @@ -218,9 +187,13 @@ def mocks_on_projects_api(mocker) -> None: @pytest.fixture -async def storage_subsystem_mock(storage_subsystem_mock, mocker: MockerFixture): +async def storage_subsystem_mock_override( + storage_subsystem_mock: MockedStorageSubsystem, mocker: MockerFixture +) -> None: """ Mocks functions that require storage client + + NOTE: overrides conftest.storage_subsystem_mock """ # Overrides + extends fixture in services/web/server/tests/unit/with_dbs/conftest.py # SEE https://docs.pytest.org/en/stable/fixture.html#override-a-fixture-on-a-folder-conftest-level @@ -252,44 +225,103 @@ async def _mock_result(): mock.side_effect = _mock_copy_data_from_project -async def test_access_to_invalid_study(client, published_project): - resp = await client.get("/study/SOME_INVALID_UUID") - content = await resp.text() +def _assert_redirected_to_error_page( + response: ClientResponse, expected_page: str, expected_status_code: int +): + # checks is a redirection + assert len(response.history) == 1 + assert response.history[0].status == 302 + + # checks fragment + fragment = response.history[0].headers["Location"] + r = urllib.parse.urlparse(fragment.removeprefix("/#")) + + assert r.path == f"/{expected_page}" - assert resp.status == web.HTTPNotFound.status_code, str(content) + params = urllib.parse.parse_qs(r.query) + assert params["status_code"] == [f"{expected_status_code}"], params -async def test_access_to_forbidden_study(client, unpublished_project): - app = client.app +async def _assert_redirected_to_study( + response: ClientResponse, session: ClientSession +) -> str: - valid_but_not_sharable = unpublished_project["uuid"] + # https://docs.aiohttp.org/en/stable/client_advanced.html#redirection-history + assert len(response.history) == 1, "Is a re-direction" - resp = await client.get("/study/valid_but_not_sharable") - content = await resp.text() + content = await response.text() + assert response.status == web.HTTPOk.status_code, f"Got {content}" + # Expects redirection to osparc web + assert response.url.path == "/" assert ( - resp.status == web.HTTPNotFound.status_code - ), f"STANDARD studies are NOT sharable: {content}" + "OSPARC-SIMCORE" in content + ), "Expected front-end rendering workbench's study, got %s" % str(content) + + # Expects fragment to indicate client where to find newly created project + m = re.match(r"/study/([\d\w-]+)", response.real_url.fragment) + assert m, f"Expected /study/uuid, got {response.real_url.fragment}" + + # Expects auth cookie for current user + assert _is_user_authenticated(session) + + # returns newly created project + redirected_project_id = m.group(1) + return redirected_project_id + + +# ----------------------------------------------------------- +# +# Covers user stories for ISAN: +# +# - The ISAN Portal (M8; MS11.b,D11.b): https://github.com/ITISFoundation/osparc-simcore/issues/501 +# - User access management for ISAN : https://github.com/ITISFoundation/osparc-simcore/issues/712 +# - Direct link to study in workbench : https://github.com/ITISFoundation/osparc-simcore/issues/730 +# +# ----------------------------------------------------------- + + +async def test_access_to_invalid_study(client: TestClient, faker: Faker): + response = await client.get(f"/study/{faker.uuid4()}") + + _assert_redirected_to_error_page( + response, + expected_page="error", + expected_status_code=web.HTTPNotFound.status_code, + ) + + +async def test_access_to_forbidden_study( + client: TestClient, unpublished_project: ProjectDict +): + response = await client.get(f"/study/{unpublished_project['uuid']}") + + _assert_redirected_to_error_page( + response, + expected_page="error", + expected_status_code=web.HTTPNotFound.status_code, + ) -@pytest.mark.flaky(max_runs=3) async def test_access_study_anonymously( - client, - published_project, - storage_subsystem_mock, - catalog_subsystem_mock, - director_v2_mock, - mocks_on_projects_api, - redis_locks_client, # needed to cleanup the locks between parametrizations + client: TestClient, + published_project: ProjectDict, + storage_subsystem_mock_override: None, + catalog_subsystem_mock: Callable[[list[ProjectDict]], None], + director_v2_service_mock: AioResponsesMock, + mocks_on_projects_api: None, + # needed to cleanup the locks between parametrizations + redis_locks_client: AsyncIterator[aioredis.Redis], ): catalog_subsystem_mock([published_project]) - assert not is_user_authenticated(client.session), "Is anonymous" + + assert not _is_user_authenticated(client.session), "Is anonymous" study_url = client.app.router["study"].url_for(id=published_project["uuid"]) resp = await client.get(study_url) - expected_prj_id = await assert_redirected_to_study(resp, client.session) + expected_prj_id = await _assert_redirected_to_study(resp, client.session) # has auto logged in as guest? me_url = client.app.router["get_my_profile"].url_for() @@ -312,29 +344,30 @@ async def test_access_study_anonymously( @pytest.fixture -async def auto_delete_projects(client) -> AsyncIterator[None]: +async def auto_delete_projects(client: TestClient) -> AsyncIterator[None]: yield await delete_all_projects(client.app) @pytest.mark.parametrize("user_role", [UserRole.USER, UserRole.TESTER]) async def test_access_study_by_logged_user( - client, - logged_user, - published_project, - storage_subsystem_mock, - catalog_subsystem_mock, - director_v2_mock, - mocks_on_projects_api, - auto_delete_projects, - redis_locks_client, # needed to cleanup the locks between parametrizations + client: TestClient, + logged_user: UserInfoDict, + published_project: ProjectDict, + storage_subsystem_mock_override: None, + catalog_subsystem_mock: Callable[[list[ProjectDict]], None], + director_v2_service_mock: AioResponsesMock, + mocks_on_projects_api: None, + auto_delete_projects: None, + # needed to cleanup the locks between parametrizations + redis_locks_client: AsyncIterator[aioredis.Redis], ): catalog_subsystem_mock([published_project]) - assert is_user_authenticated(client.session), "Is already logged-in" + assert _is_user_authenticated(client.session), "Is already logged-in" study_url = client.app.router["study"].url_for(id=published_project["uuid"]) resp = await client.get(study_url) - await assert_redirected_to_study(resp, client.session) + await _assert_redirected_to_study(resp, client.session) # user has a copy of the template project projects = await _get_user_projects(client) @@ -349,13 +382,14 @@ async def test_access_study_by_logged_user( async def test_access_cookie_of_expired_user( - client, - published_project, - storage_subsystem_mock, - catalog_subsystem_mock, - director_v2_mock, - mocks_on_projects_api, - redis_locks_client, # needed to cleanup the locks between parametrizations + client: TestClient, + published_project: ProjectDict, + storage_subsystem_mock_override: None, + catalog_subsystem_mock: Callable[[list[ProjectDict]], None], + director_v2_service_mock: AioResponsesMock, + mocks_on_projects_api: None, + # needed to cleanup the locks between parametrizations + redis_locks_client: AsyncIterator[aioredis.Redis], ): catalog_subsystem_mock([published_project]) # emulates issue #1570 @@ -364,7 +398,7 @@ async def test_access_cookie_of_expired_user( study_url = app.router["study"].url_for(id=published_project["uuid"]) resp = await client.get(study_url) - await assert_redirected_to_study(resp, client.session) + await _assert_redirected_to_study(resp, client.session) # Expects valid cookie and GUEST access me_url = app.router["get_my_profile"].url_for() @@ -400,7 +434,7 @@ async def enforce_garbage_collect_guest(uid): # But still can access as a new user resp = await client.get(study_url) - await assert_redirected_to_study(resp, client.session) + await _assert_redirected_to_study(resp, client.session) # as a guest user resp = await client.get(me_url) @@ -414,15 +448,16 @@ async def enforce_garbage_collect_guest(uid): @pytest.mark.parametrize("number_of_simultaneous_requests", [1, 2, 64]) async def test_guest_user_is_not_garbage_collected( - number_of_simultaneous_requests, - web_server, - aiohttp_client, - published_project, - storage_subsystem_mock, - catalog_subsystem_mock, - director_v2_mock, - mocks_on_projects_api, - redis_locks_client, # needed to cleanup the locks between parametrizations + number_of_simultaneous_requests: int, + web_server: TestServer, + aiohttp_client: Callable, + published_project: ProjectDict, + storage_subsystem_mock_override: None, + catalog_subsystem_mock: Callable[[list[ProjectDict]], None], + director_v2_service_mock: AioResponsesMock, + mocks_on_projects_api: None, + # needed to cleanup the locks between parametrizations + redis_locks_client: AsyncIterator[aioredis.Redis], ): catalog_subsystem_mock([published_project]) ## NOTE: use pytest -s --log-cli-level=DEBUG to see GC logs @@ -440,7 +475,7 @@ async def _test_guest_user_workflow(request_index): # clicks link to study resp = await client.get(f"{study_url}") - expected_prj_id = await assert_redirected_to_study(resp, client.session) + expected_prj_id = await _assert_redirected_to_study(resp, client.session) # has auto logged in as guest? me_url = client.app.router["get_my_profile"].url_for() diff --git a/services/web/server/tests/unit/with_dbs/02/test_project_db.py b/services/web/server/tests/unit/with_dbs/02/test_project_db.py index 1ec239a9836..c94b39726fb 100644 --- a/services/web/server/tests/unit/with_dbs/02/test_project_db.py +++ b/services/web/server/tests/unit/with_dbs/02/test_project_db.py @@ -29,6 +29,7 @@ from simcore_postgres_database.models.users import UserRole from simcore_service_webserver.projects.project_models import ProjectDict from simcore_service_webserver.projects.projects_db import ( + ANY_USER, ProjectAccessRights, ProjectDBAPI, ProjectInvalidRightsError, @@ -40,7 +41,7 @@ from simcore_service_webserver.projects.projects_db_utils import ( DB_EXCLUSIVE_COLUMNS, SCHEMA_NON_NULL_KEYS, - Permission, + PermissionStr, ) from simcore_service_webserver.projects.projects_exceptions import ( NodeNotFoundError, @@ -129,6 +130,17 @@ def all_permission_combinations() -> list[str]: return res +def test_check_project_permissions_for_any_user(): + project = {"access_rights": {"1": {"read": True, "write": False, "delete": False}}} + + check_project_permissions( + project, + user_id=ANY_USER, + user_groups=[{"gid": 1, "type": GroupType.EVERYONE}], + permission="read", + ) + + @pytest.mark.parametrize("wanted_permissions", all_permission_combinations()) def test_check_project_permissions( user_id: int, @@ -348,7 +360,6 @@ async def test_insert_project_to_db( db_api: ProjectDBAPI, osparc_product_name: str, ): - original_project = deepcopy(fake_project) # add project without user id -> by default creates a template @@ -949,7 +960,7 @@ async def test_has_permission( product_name=osparc_product_name, ) - for permission in get_args(Permission): + for permission in get_args(PermissionStr): assert permission in access_rights # owner always is allowed to do everything diff --git a/services/web/server/tests/unit/with_dbs/02/test_projects_cancellations.py b/services/web/server/tests/unit/with_dbs/02/test_projects_cancellations.py index 9f44c0bbfb6..07794fa3af9 100644 --- a/services/web/server/tests/unit/with_dbs/02/test_projects_cancellations.py +++ b/services/web/server/tests/unit/with_dbs/02/test_projects_cancellations.py @@ -69,7 +69,7 @@ async def test_copying_large_project_and_aborting_correctly_removes_new_project( standard_groups: list[dict[str, str]], user_project: dict[str, Any], expected: ExpectedResponse, - catalog_subsystem_mock: Callable, + catalog_subsystem_mock: Callable[[list[ProjectDict]], None], slow_storage_subsystem_mock: MockedStorageSubsystem, project_db_cleaner: None, ): @@ -121,7 +121,7 @@ async def test_copying_large_project_and_retrieving_copy_task( standard_groups: list[dict[str, str]], user_project: dict[str, Any], expected: ExpectedResponse, - catalog_subsystem_mock: Callable, + catalog_subsystem_mock: Callable[[list[ProjectDict]], None], slow_storage_subsystem_mock: MockedStorageSubsystem, project_db_cleaner: None, ): @@ -167,7 +167,7 @@ async def test_creating_new_project_from_template_without_copying_data_creates_s standard_groups: list[dict[str, str]], template_project: dict[str, Any], expected: ExpectedResponse, - catalog_subsystem_mock: Callable, + catalog_subsystem_mock: Callable[[list[ProjectDict]], None], slow_storage_subsystem_mock: MockedStorageSubsystem, project_db_cleaner: None, request_create_project: Callable[..., Awaitable[ProjectDict]], @@ -217,7 +217,7 @@ async def test_creating_new_project_as_template_without_copying_data_creates_ske standard_groups: list[dict[str, str]], user_project: dict[str, Any], expected: ExpectedResponse, - catalog_subsystem_mock: Callable, + catalog_subsystem_mock: Callable[[list[ProjectDict]], None], slow_storage_subsystem_mock: MockedStorageSubsystem, project_db_cleaner: None, request_create_project: Callable[..., Awaitable[ProjectDict]], diff --git a/services/web/server/tests/unit/with_dbs/02/test_projects_handlers__delete.py b/services/web/server/tests/unit/with_dbs/02/test_projects_handlers__delete.py index 34661201aaf..e4b055b9059 100644 --- a/services/web/server/tests/unit/with_dbs/02/test_projects_handlers__delete.py +++ b/services/web/server/tests/unit/with_dbs/02/test_projects_handlers__delete.py @@ -25,6 +25,7 @@ from simcore_service_webserver._meta import api_version_prefix from simcore_service_webserver.db_models import UserRole from simcore_service_webserver.projects import _delete +from simcore_service_webserver.projects.project_models import ProjectDict from simcore_service_webserver.projects.projects_api import lock_with_notification from socketio.exceptions import ConnectionError as SocketConnectionError @@ -47,7 +48,7 @@ async def test_delete_project( expected: ExpectedResponse, storage_subsystem_mock: MockedStorageSubsystem, mocked_director_v2_api: dict[str, MagicMock], - catalog_subsystem_mock: Callable, + catalog_subsystem_mock: Callable[[list[ProjectDict]], None], fake_services: Callable, assert_get_same_project_caller: Callable, mock_rabbitmq: None, diff --git a/services/web/server/tests/unit/with_dbs/02/test_projects_handlers__list.py b/services/web/server/tests/unit/with_dbs/02/test_projects_handlers__list.py index e359fee53b1..906a9bd7bdf 100644 --- a/services/web/server/tests/unit/with_dbs/02/test_projects_handlers__list.py +++ b/services/web/server/tests/unit/with_dbs/02/test_projects_handlers__list.py @@ -5,7 +5,7 @@ import asyncio from math import ceil -from typing import Any, Awaitable, Callable, Optional, Union +from typing import Any, Awaitable, Callable, Optional import pytest from aiohttp import web @@ -133,7 +133,7 @@ async def test_list_projects_with_invalid_pagination_parameters( primary_group: dict[str, str], expected: ExpectedResponse, storage_subsystem_mock, - catalog_subsystem_mock: Callable[[Optional[Union[list[dict], dict]]], None], + catalog_subsystem_mock: Callable[[list[ProjectDict]], None], director_v2_service_mock: aioresponses, project_db_cleaner, limit: int, @@ -157,7 +157,7 @@ async def test_list_projects_with_pagination( primary_group: dict[str, str], expected: ExpectedResponse, storage_subsystem_mock, - catalog_subsystem_mock: Callable[[Optional[Union[list[dict], dict]]], None], + catalog_subsystem_mock: Callable[[list[ProjectDict]], None], director_v2_service_mock: aioresponses, project_db_cleaner, limit: int, diff --git a/services/web/server/tests/unit/with_dbs/02/test_projects_handlers__open_close.py b/services/web/server/tests/unit/with_dbs/02/test_projects_handlers__open_close.py index 548a866fbcc..6f8d791aad0 100644 --- a/services/web/server/tests/unit/with_dbs/02/test_projects_handlers__open_close.py +++ b/services/web/server/tests/unit/with_dbs/02/test_projects_handlers__open_close.py @@ -237,7 +237,7 @@ async def test_share_project( expected: ExpectedResponse, storage_subsystem_mock, mocked_director_v2_api: dict[str, mock.Mock], - catalog_subsystem_mock, + catalog_subsystem_mock: Callable[[list[ProjectDict]], None], share_rights: dict, project_db_cleaner, request_create_project: Callable[..., Awaitable[ProjectDict]], diff --git a/services/web/server/tests/unit/with_dbs/02/test_projects_handlers_crud.py b/services/web/server/tests/unit/with_dbs/02/test_projects_handlers_crud.py index 1771f6b0e91..2a327fd57d7 100644 --- a/services/web/server/tests/unit/with_dbs/02/test_projects_handlers_crud.py +++ b/services/web/server/tests/unit/with_dbs/02/test_projects_handlers_crud.py @@ -7,7 +7,7 @@ import uuid as uuidlib from copy import deepcopy from math import ceil -from typing import Any, Awaitable, Callable, Iterator, Optional, Union +from typing import Any, Awaitable, Callable, Iterator, Optional import pytest import sqlalchemy as sa @@ -174,7 +174,7 @@ async def test_list_projects( user_project: dict[str, Any], template_project: dict[str, Any], expected: type[web.HTTPException], - catalog_subsystem_mock: Callable[[Optional[Union[list[dict], dict]]], None], + catalog_subsystem_mock: Callable[[list[ProjectDict]], None], director_v2_service_mock: aioresponses, ): catalog_subsystem_mock([user_project, template_project]) @@ -251,7 +251,7 @@ async def test_list_projects_with_innaccessible_services( user_project: dict[str, Any], template_project: dict[str, Any], expected: type[web.HTTPException], - catalog_subsystem_mock: Callable[[Optional[Union[list[dict], dict]]], None], + catalog_subsystem_mock: Callable[[list[ProjectDict]], None], director_v2_service_mock: aioresponses, postgres_db: sa.engine.Engine, s4l_product_headers: dict[str, Any], @@ -295,7 +295,7 @@ async def test_get_project( user_project, template_project, expected, - catalog_subsystem_mock, + catalog_subsystem_mock: Callable[[list[ProjectDict]], None], ): catalog_subsystem_mock([user_project, template_project]) @@ -359,7 +359,7 @@ async def test_new_project_from_other_study( user_project, expected, storage_subsystem_mock, - catalog_subsystem_mock, + catalog_subsystem_mock: Callable[[list[ProjectDict]], None], project_db_cleaner, request_create_project: Callable[..., Awaitable[ProjectDict]], ): @@ -449,7 +449,7 @@ async def test_new_template_from_project( user_project: dict[str, Any], expected: ExpectedResponse, storage_subsystem_mock: MockedStorageSubsystem, - catalog_subsystem_mock: Callable, + catalog_subsystem_mock: Callable[[list[ProjectDict]], None], project_db_cleaner: None, request_create_project: Callable[..., Awaitable[ProjectDict]], ): diff --git a/services/web/server/tests/unit/with_dbs/03/tags/test_tags.py b/services/web/server/tests/unit/with_dbs/03/tags/test_tags.py index 9f484bc835d..01c2b64a5de 100644 --- a/services/web/server/tests/unit/with_dbs/03/tags/test_tags.py +++ b/services/web/server/tests/unit/with_dbs/03/tags/test_tags.py @@ -27,6 +27,7 @@ from simcore_service_webserver._meta import api_version_prefix from simcore_service_webserver.db import get_database_engine from simcore_service_webserver.db_models import UserRole +from simcore_service_webserver.projects.project_models import ProjectDict @pytest.mark.parametrize( @@ -64,7 +65,7 @@ async def test_tags_to_studies( user_project, expected: type[web.HTTPException], fake_tags: dict[str, Any], - catalog_subsystem_mock: Callable, + catalog_subsystem_mock: Callable[[list[ProjectDict]], None], ): catalog_subsystem_mock([user_project]) assert client.app diff --git a/services/web/server/tests/unit/with_dbs/03/version_control/conftest.py b/services/web/server/tests/unit/with_dbs/03/version_control/conftest.py index cf3b8b0eecd..ef59563508a 100644 --- a/services/web/server/tests/unit/with_dbs/03/version_control/conftest.py +++ b/services/web/server/tests/unit/with_dbs/03/version_control/conftest.py @@ -5,7 +5,7 @@ import logging from copy import deepcopy from pathlib import Path -from typing import Any, AsyncIterator, Awaitable, Callable, Optional, Union +from typing import Any, AsyncIterator, Awaitable, Callable from unittest import mock from uuid import UUID @@ -55,16 +55,19 @@ def fake_project(faker: Faker) -> ProjectDict: @pytest.fixture -async def catalog_subsystem_mock( - catalog_subsystem_mock: Callable[[Optional[Union[list[dict], dict]]], None], - fake_project, +async def catalog_subsystem_mock_override( + catalog_subsystem_mock: Callable[[list[ProjectDict]], None], + fake_project: ProjectDict, ) -> None: catalog_subsystem_mock([fake_project]) @pytest.fixture def app_cfg( - default_app_cfg, unused_tcp_port_factory, catalog_subsystem_mock, monkeypatch + default_app_cfg, + unused_tcp_port_factory, + catalog_subsystem_mock_override: None, + monkeypatch, ) -> dict[str, Any]: """App's configuration used for every test in this module diff --git a/services/web/server/tests/unit/with_dbs/conftest.py b/services/web/server/tests/unit/with_dbs/conftest.py index fb8edcce4dc..c70e5f252cb 100644 --- a/services/web/server/tests/unit/with_dbs/conftest.py +++ b/services/web/server/tests/unit/with_dbs/conftest.py @@ -15,7 +15,7 @@ import textwrap from copy import deepcopy from pathlib import Path -from typing import Any, AsyncIterator, Callable, Iterator, Optional, Union +from typing import Any, AsyncIterator, Callable, Iterator from unittest.mock import AsyncMock, MagicMock, Mock from uuid import uuid4 @@ -51,6 +51,7 @@ delete_user_group, list_user_groups, ) +from simcore_service_webserver.projects.project_models import ProjectDict CURRENT_DIR = Path(sys.argv[0] if __name__ == "__main__" else __file__).resolve().parent @@ -179,6 +180,10 @@ def client( mock_orphaned_services, redis_client: Redis, ) -> TestClient: + """ + Deployed web-server + postgres + redis services + client connect to web-server + """ # WARNING: this fixture is commonly overriden. Check before renaming. cli = event_loop.run_until_complete(aiohttp_client(web_server)) return cli @@ -197,11 +202,14 @@ def osparc_product_name() -> str: @pytest.fixture async def catalog_subsystem_mock( - monkeypatch, -) -> Callable[[Optional[Union[list[dict], dict]]], None]: + monkeypatch: MonkeyPatch, +) -> Callable[[list[ProjectDict]], None]: + """ + Patches some API calls in the catalog plugin + """ services_in_project = [] - def creator(projects: Optional[Union[list[dict], dict]] = None) -> None: + def _creator(projects: list[ProjectDict]) -> None: for proj in projects or []: services_in_project.extend( [ @@ -217,18 +225,18 @@ async def mocked_get_services_for_user(*args, **kwargs): catalog, "get_services_for_user_in_product", mocked_get_services_for_user ) - return creator + return _creator @pytest.fixture def disable_static_webserver(monkeypatch: MonkeyPatch) -> Callable: """ - Disables the static-webserver module. + Disables the static-webserver module Avoids fecthing and caching index.html pages - Mocking a response for all the services which expect it. + Mocking a response for all the services which expect it """ - async def _mocked_index_html(request: web.Request) -> web.Response: + async def fake_front_end_handler(request: web.Request) -> web.Response: """ Emulates the reply of the '/' path when the static-webserver is disabled """ @@ -238,7 +246,13 @@ async def _mocked_index_html(request: web.Request) -> web.Response:

OSPARC-SIMCORE

-

This is a result of disable_static_webserver fixture for product OSPARC ({__file__})

+

This is a result of disable_static_webserver fixture for product OSPARC ({__name__})

+

Request info

+ """ @@ -249,13 +263,13 @@ async def _mocked_index_html(request: web.Request) -> web.Response: monkeypatch.setenv("WEBSERVER_STATICWEB", "null") def add_index_route(app: web.Application) -> None: - app.router.add_get("/", _mocked_index_html, name=INDEX_RESOURCE_NAME) + app.router.add_get("/", fake_front_end_handler, name=INDEX_RESOURCE_NAME) return add_index_route @pytest.fixture -async def storage_subsystem_mock(mocker) -> MockedStorageSubsystem: +async def storage_subsystem_mock(mocker: MonkeyPatch) -> MockedStorageSubsystem: """ Patches client calls to storage service