diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 66a64cbcbf1f..e80d704f7dce 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -24,8 +24,19 @@ or from https://gitmoji.dev/ ## What do these changes do? - diff --git a/.github/instructions/python-tests.instructions.md b/.github/instructions/python-tests.instructions.md index fe82ce263e09..3a070947b9ba 100644 --- a/.github/instructions/python-tests.instructions.md +++ b/.github/instructions/python-tests.instructions.md @@ -26,12 +26,15 @@ This is a multi-project monorepo with two main groups of projects in the folders ### File size - If a test file exceeds 1000 lines, split it into multiple files using descriptive name suffixes (e.g. `test_users_accounts_rest_registration.py` → `test_users_accounts_rest_registration_create.py`, `test_users_accounts_rest_registration_delete.py`, etc.). -- If the split files share fixtures, move shared fixtures to a `conftest.py`. If the number of files warrants it, group them into a subfolder with its own `conftest.py` (e.g. `test_users_accounts_rest_registration/test_*.py` + `test_users_accounts_rest_registration/conftest.py`). -``` +- If the split files share fixtures, move shared fixtures to a `conftest.py`. If the number of files warrants it, group them into a subfolder with its own `conftest.py`. +- **Test file names must be unique across the entire project test tree**, regardless of which subfolder they live in. Generic names like `test_list.py`, `test_search.py`, or `test_create.py` are forbidden since they can easily collide with unrelated test files elsewhere. Always keep the full original filename as a prefix for the filename, even inside a dedicated subfolder. **Folder names should be a short version of the original test filename, without the `test_` prefix**: + - ✅ `users_accounts_rest_registration/test_users_accounts_rest_registration_search.py` + - ❌ `test_users_accounts_rest_registration/test_users_accounts_rest_registration_search.py` + - ❌ `users_accounts/test_search.py` ## How to Run Tests See the [`run-python-tests`](../skills/run-python-tests/SKILL.md) skill for the full step-by-step procedure. --- -*Last updated: 2026-03-23* +*Last updated: 2026-04-15* diff --git a/.github/skills/postgres-migration/SKILL.md b/.github/skills/postgres-migration/SKILL.md index d2a48a8a3dca..7e55a5077fb4 100644 --- a/.github/skills/postgres-migration/SKILL.md +++ b/.github/skills/postgres-migration/SKILL.md @@ -125,3 +125,6 @@ This skill **completes** with the generation and validation of the migration scr | Migration file not generated | Check the logs from `sc-pg review` for errors; verify model changes were saved to disk | | Migration missing some changes | Manually edit the migration file (Python code) to add missing operations (see Step 5) | | Adminer URL not accessible | Check Docker logs with `docker logs ` or look at `make setup-commit` output | + +--- +*Last updated: 2026-03-10* diff --git a/.github/skills/run-python-tests/SKILL.md b/.github/skills/run-python-tests/SKILL.md index 1f3cb8227972..73db6bbc40ce 100644 --- a/.github/skills/run-python-tests/SKILL.md +++ b/.github/skills/run-python-tests/SKILL.md @@ -1,6 +1,6 @@ --- name: run-python-tests -description: 'Run Python tests for any service or package in this monorepo. Use when: running pytest, executing unit tests, running integration tests, test failures, make install-dev, test setup, installing test dependencies.' +description: 'Run Python tests and static analysis for any service or package in this monorepo. Use when: running pytest, executing unit tests, running integration tests, test failures, make install-dev, test setup, installing test dependencies, linting with pylint, and type checking with mypy.' --- # Run Python Tests diff --git a/packages/models-library/src/models_library/api_schemas_webserver/users.py b/packages/models-library/src/models_library/api_schemas_webserver/users.py index 8f9a0bf4a979..34dd8de2ef49 100644 --- a/packages/models-library/src/models_library/api_schemas_webserver/users.py +++ b/packages/models-library/src/models_library/api_schemas_webserver/users.py @@ -315,6 +315,7 @@ class UsersForAdminListFilter(Filters): # CONFIRMATION_PENDING, ACTIVE, EXPIRED, BANNED, DELETED # review_status: Literal["PENDING", "REVIEWED"] | None = None + registered: bool | None = None product_name: ProductName | None = None model_config = ConfigDict(extra="forbid") diff --git a/services/web/server/VERSION b/services/web/server/VERSION index 11df8c62693a..92fc430ae8f3 100644 --- a/services/web/server/VERSION +++ b/services/web/server/VERSION @@ -1 +1 @@ -0.81.1 +0.82.0 diff --git a/services/web/server/setup.cfg b/services/web/server/setup.cfg index 3a82abc4ce56..2708572858fa 100644 --- a/services/web/server/setup.cfg +++ b/services/web/server/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.81.1 +current_version = 0.82.0 commit = True message = services/webserver api version: {current_version} → {new_version} tag = False diff --git a/services/web/server/src/simcore_service_webserver/api/v0/openapi.json b/services/web/server/src/simcore_service_webserver/api/v0/openapi.json index 6d324b0f330a..1cc3f70c4e0d 100644 --- a/services/web/server/src/simcore_service_webserver/api/v0/openapi.json +++ b/services/web/server/src/simcore_service_webserver/api/v0/openapi.json @@ -3,7 +3,7 @@ "info": { "title": "simcore-service-webserver", "description": "Main service with an interface (http-API & websockets) to the web front-end", - "version": "0.81.1" + "version": "0.82.0" }, "servers": [ { @@ -2710,6 +2710,22 @@ "title": "Review Status" } }, + { + "name": "registered", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Registered" + } + }, { "name": "product_name", "in": "query", diff --git a/services/web/server/src/simcore_service_webserver/users/_accounts_repository.py b/services/web/server/src/simcore_service_webserver/users/_accounts_repository.py index 185bffb0c443..7d94257f2de2 100644 --- a/services/web/server/src/simcore_service_webserver/users/_accounts_repository.py +++ b/services/web/server/src/simcore_service_webserver/users/_accounts_repository.py @@ -546,6 +546,7 @@ async def list_merged_pre_and_registered_users( *, product_name: ProductName, filter_any_account_request_status: list[AccountRequestStatus] | None = None, + filter_registered: bool | None = None, filter_include_deleted: bool = False, pagination_limit: int = 50, pagination_offset: int = 0, @@ -563,6 +564,8 @@ async def list_merged_pre_and_registered_users( product_name: Product name to filter by filter_any_account_request_status: If provided, only returns users with account request status in this list (only pre-registered users with any of these statuses will be included) + filter_registered: If provided, filters records by registration completion + (True => linked to a user, False => not linked) filter_include_deleted: Whether to include deleted users pagination_limit: Maximum number of results to return pagination_offset: Number of results to skip (for pagination) @@ -667,17 +670,26 @@ async def list_merged_pre_and_registered_users( merged_query: sa.sql.Select | sa.sql.CompoundSelect merged_query = pre_reg_query if filter_any_account_request_status else pre_reg_query.union_all(users_query) - # Add distinct on email to eliminate duplicates + # Apply optional registration linkage filter on the merged view before pagination/count merged_query_subq = merged_query.subquery() + filtered_query = sa.select(merged_query_subq).select_from(merged_query_subq) + if filter_registered is True: + filtered_query = filtered_query.where(merged_query_subq.c.user_id.is_not(None)) + elif filter_registered is False: + filtered_query = filtered_query.where(merged_query_subq.c.user_id.is_(None)) + + filtered_query_subq = filtered_query.subquery() + + # Add distinct on email to eliminate duplicates distinct_query = ( - sa.select(merged_query_subq) - .select_from(merged_query_subq) - .distinct(merged_query_subq.c.email) + sa.select(filtered_query_subq) + .select_from(filtered_query_subq) + .distinct(filtered_query_subq.c.email) .order_by( - merged_query_subq.c.email, + filtered_query_subq.c.email, # Prioritize pre-registration records if duplicate emails exist - merged_query_subq.c.is_pre_registered.desc(), - merged_query_subq.c.created.desc(), + filtered_query_subq.c.is_pre_registered.desc(), + filtered_query_subq.c.created.desc(), ) .limit(pagination_limit) .offset(pagination_offset) @@ -685,7 +697,7 @@ async def list_merged_pre_and_registered_users( # Count query (for pagination) count_query = sa.select(sa.func.count().label("total")).select_from( - sa.select(merged_query_subq.c.email).select_from(merged_query_subq).distinct().subquery() + sa.select(filtered_query_subq.c.email).select_from(filtered_query_subq).distinct().subquery() ) async with pass_or_acquire_connection(engine, connection) as conn: diff --git a/services/web/server/src/simcore_service_webserver/users/_accounts_service.py b/services/web/server/src/simcore_service_webserver/users/_accounts_service.py index 3cafb55ed562..1d7ca4eb9b5e 100644 --- a/services/web/server/src/simcore_service_webserver/users/_accounts_service.py +++ b/services/web/server/src/simcore_service_webserver/users/_accounts_service.py @@ -18,7 +18,7 @@ from ..products import products_service from ..products.errors import ProductNotFoundError from . import _accounts_repository, _users_repository -from ._models import PreviewApproval +from ._models import PreviewApproval, PreviewRejection from .exceptions import ( AlreadyPreRegisteredError, PendingPreRegistrationNotFoundError, @@ -122,6 +122,10 @@ async def list_user_accounts( list[AccountRequestStatus] | None, doc("List of any account request statuses to filter by"), ] = None, + filter_registered: Annotated[ + bool | None, + doc("Filters by registration completion status"), + ] = None, pagination_limit: int = 50, pagination_offset: int = 0, ) -> Annotated[ @@ -141,6 +145,7 @@ async def list_user_accounts( engine, product_name=product_name, filter_any_account_request_status=filter_any_account_request_status, + filter_registered=filter_registered, pagination_limit=pagination_limit, pagination_offset=pagination_offset, ) @@ -430,3 +435,47 @@ async def preview_approval_user_account( invitation_url=invitation_url, message_content=preview.message_content, ) + + +async def preview_rejection_user_account( + app: web.Application, + *, + rejection_email: str, + product_name: ProductName, +) -> PreviewRejection: + """Preview the rejection notification for a user account. + + Retrieves user pre-registration data and generates a preview of the + account_rejected email template. + + Raises: + PendingPreRegistrationNotFoundError: If no pre-registration is found for the email/product + """ + found = await search_users_accounts( + app, + filter_by_email_glob=rejection_email, + product_name=product_name, + include_products=False, + ) + + if not found: + raise PendingPreRegistrationNotFoundError(email=rejection_email, product_name=product_name) + + user_account = found[0] + assert user_account.email == rejection_email # nosec + + preview = await notifications_service.preview_template( + app=app, + product_name=product_name, + ref=TemplateRef( + channel=Channel.email, + template_name="account_rejected", + ), + context={ + "user": { + "first_name": user_account.first_name, + }, + }, + ) + + return PreviewRejection(message_content=preview.message_content) diff --git a/services/web/server/src/simcore_service_webserver/users/_controller/rest/accounts_rest.py b/services/web/server/src/simcore_service_webserver/users/_controller/rest/accounts_rest.py index 291e035efc3d..17d2a2009331 100644 --- a/services/web/server/src/simcore_service_webserver/users/_controller/rest/accounts_rest.py +++ b/services/web/server/src/simcore_service_webserver/users/_controller/rest/accounts_rest.py @@ -5,7 +5,6 @@ from common_library.user_messages import user_message from common_library.users_enums import AccountRequestStatus from models_library.api_schemas_invitations.invitations import ApiInvitationInputs -from models_library.api_schemas_webserver.notifications import MessageContentGet from models_library.api_schemas_webserver.users import ( UserAccountApprove, UserAccountGet, @@ -19,10 +18,8 @@ UserAccountSearchQueryParams, UsersAccountListQueryParams, ) -from models_library.notifications import Channel from models_library.rest_pagination import Page from models_library.rest_pagination_utils import paginate_data -from pydantic import TypeAdapter from servicelib.aiohttp import status from servicelib.aiohttp.requests_validation import ( parse_request_body_as, @@ -34,8 +31,6 @@ from ...._meta import API_VTAG from ....invitations import api as invitations_service from ....login.decorators import login_required -from ....notifications import notifications_service -from ....notifications._models import TemplateRef from ....products import products_service from ....products.errors import ProductNotFoundError from ....security.decorators import ( @@ -97,6 +92,7 @@ async def list_users_accounts(request: web.Request) -> web.Response: request.app, product_name=target_product_name, filter_any_account_request_status=filter_any_account_request_status, + filter_registered=query_params.registered, pagination_limit=query_params.limit, pagination_offset=query_params.offset, ) @@ -329,31 +325,13 @@ async def preview_rejection_user_account(request: web.Request) -> web.Response: assert req_ctx.product_name # nosec rejection_data = await parse_request_body_as(UserAccountPreviewRejection, request) - found = await _accounts_service.search_users_accounts( - request.app, - filter_by_email_glob=rejection_data.email, - product_name=req_ctx.product_name, - include_products=False, - ) - user_account = found[0] - assert user_account.email == rejection_data.email # nosec - preview = await notifications_service.preview_template( - app=request.app, + preview_result = await _accounts_service.preview_rejection_user_account( + request.app, + rejection_email=rejection_data.email, product_name=req_ctx.product_name, - ref=TemplateRef( - channel=Channel.email, - template_name="account_rejected", - ), - context={ - "user": { - "first_name": user_account.first_name, - }, - }, ) - response = UserAccountPreviewRejectionGet( - message_content=TypeAdapter(MessageContentGet).validate_python(preview.message_content), - ) + response = UserAccountPreviewRejectionGet(**preview_result.model_dump()) return envelope_json_response(response.model_dump(**_RESPONSE_MODEL_MINIMAL_POLICY)) diff --git a/services/web/server/src/simcore_service_webserver/users/_models.py b/services/web/server/src/simcore_service_webserver/users/_models.py index 3f4ab091cd2b..c16f545357cb 100644 --- a/services/web/server/src/simcore_service_webserver/users/_models.py +++ b/services/web/server/src/simcore_service_webserver/users/_models.py @@ -80,3 +80,7 @@ class UserCredentialsTuple(NamedTuple): class PreviewApproval(BaseModel): invitation_url: str message_content: dict[str, Any] + + +class PreviewRejection(BaseModel): + message_content: dict[str, Any] diff --git a/services/web/server/tests/unit/with_dbs/03/invitations/test_users_accounts_rest_registration.py b/services/web/server/tests/unit/with_dbs/03/invitations/test_users_accounts_rest_registration.py deleted file mode 100644 index e998f05d7f44..000000000000 --- a/services/web/server/tests/unit/with_dbs/03/invitations/test_users_accounts_rest_registration.py +++ /dev/null @@ -1,1437 +0,0 @@ -# pylint: disable=protected-access -# pylint: disable=redefined-outer-name -# pylint: disable=too-many-arguments -# pylint: disable=too-many-statements -# pylint: disable=unused-argument -# pylint: disable=unused-variable - - -from collections.abc import AsyncGenerator, AsyncIterator -from http import HTTPStatus -from typing import Any -from unittest.mock import AsyncMock - -import pytest -import sqlalchemy as sa -from aiohttp.test_utils import TestClient -from common_library.pydantic_fields_extension import is_nullable -from common_library.users_enums import UserRole, UserStatus -from faker import Faker -from models_library.api_schemas_webserver.auth import AccountRequestInfo -from models_library.api_schemas_webserver.users import ( - UserAccountGet, - UserAccountPreviewApprovalGet, - UserAccountPreviewRejectionGet, - UserAccountProductOptionGet, -) -from models_library.groups import AccessRightsDict -from models_library.notifications import Channel -from models_library.products import ProductName -from models_library.rest_error import ErrorGet -from models_library.rest_pagination import Page -from pydantic import TypeAdapter -from pytest_mock import MockerFixture -from pytest_simcore.aioresponses_mocker import AioResponsesMock -from pytest_simcore.helpers.assert_checks import assert_status -from pytest_simcore.helpers.faker_factories import ( - DEFAULT_TEST_PASSWORD, -) -from pytest_simcore.helpers.monkeypatch_envs import setenvs_from_dict -from pytest_simcore.helpers.typing_env import EnvVarsDict -from pytest_simcore.helpers.webserver_login import ( - UserInfoDict, -) -from pytest_simcore.helpers.webserver_users import NewUser -from servicelib.aiohttp import status -from servicelib.rest_constants import X_PRODUCT_NAME_HEADER -from simcore_postgres_database.models.users import users -from simcore_postgres_database.models.users_details import ( - users_pre_registration_details, -) -from simcore_service_webserver.db.plugin import get_asyncpg_engine -from simcore_service_webserver.login import _auth_service -from simcore_service_webserver.models import PhoneNumberStr -from simcore_service_webserver.notifications._models import TemplatePreview, TemplateRef - - -@pytest.fixture -def app_environment(app_environment: EnvVarsDict, monkeypatch: pytest.MonkeyPatch) -> EnvVarsDict: - # disables GC and DB-listener - return app_environment | setenvs_from_dict( - monkeypatch, - { - "WEBSERVER_GARBAGE_COLLECTOR": "null", - "WEBSERVER_DB_LISTENER": "0", - }, - ) - - -@pytest.fixture -def mock_notifications_send_message(mocker: MockerFixture) -> AsyncMock: - """Mock the notifications_service.send_message to avoid RabbitMQ dependency.""" - return mocker.patch( - "simcore_service_webserver.notifications.notifications_service.send_message", - return_value=AsyncMock(), - ) - - -@pytest.fixture -async def support_user( - support_group_before_app_starts: dict, - client: TestClient, -) -> AsyncIterator[UserInfoDict]: - """Creates an active user that belongs to the product's support group.""" - async with NewUser( - user_data={ - "name": "support-user", - "status": UserStatus.ACTIVE.name, - "role": UserRole.USER.name, - }, - app=client.app, - ) as user_info: - # Add the user to the support group - assert client.app - - from simcore_service_webserver.groups import _groups_repository # noqa: PLC0415 - - # Now add user to support group with read-only access - await _groups_repository.add_new_user_in_group( - client.app, - group_id=support_group_before_app_starts["gid"], - new_user_id=user_info["id"], - access_rights=AccessRightsDict(read=True, write=False, delete=False), - ) - - yield user_info - - -@pytest.mark.parametrize( - "user_role,expected", - [ - (UserRole.ANONYMOUS, status.HTTP_401_UNAUTHORIZED), - *((role, status.HTTP_403_FORBIDDEN) for role in UserRole if UserRole.ANONYMOUS < role < UserRole.PRODUCT_OWNER), - (UserRole.PRODUCT_OWNER, status.HTTP_200_OK), - (UserRole.ADMIN, status.HTTP_200_OK), - ], -) -async def test_access_rights_on_search_users_only_product_owners_can_access( - client: TestClient, - logged_user: UserInfoDict, - expected: HTTPStatus, - pre_registration_details_db_cleanup: None, -): - assert client.app - - url = client.app.router["search_user_accounts"].url_for() - assert url.path == "/v0/admin/user-accounts:search" - - resp = await client.get(url.path, params={"email": "do-not-exists@foo.com"}) - await assert_status(resp, expected) - - -async def test_access_rights_on_search_users_support_user_can_access_when_above_guest( - support_user: UserInfoDict, - # keep support_user first since it has to be created before the app starts - client: TestClient, - pre_registration_details_db_cleanup: None, -): - """Test that support users with role > GUEST can access the search endpoint.""" - assert client.app - - from pytest_simcore.helpers.webserver_login import switch_client_session_to # noqa: PLC0415 - - # Switch client session to the support user - async with switch_client_session_to(client, support_user): - url = client.app.router["search_user_accounts"].url_for() - assert url.path == "/v0/admin/user-accounts:search" - - resp = await client.get(url.path, params={"email": "do-not-exists@foo.com"}) - await assert_status(resp, status.HTTP_200_OK) - - -@pytest.fixture -def account_request_form( - faker: Faker, - user_phone_number: PhoneNumberStr, -) -> dict[str, Any]: - # This is AccountRequestInfo.form - form = { - "firstName": faker.first_name(), - "lastName": faker.last_name(), - "email": faker.email(), - "phone": user_phone_number, - "company": faker.company(), - # billing info - "address": faker.address().replace("\n", ", "), - "city": faker.city(), - "postalCode": faker.postcode(), - "country": faker.country(), - # extras - "application": faker.word(), - "description": faker.sentence(), - "hear": faker.word(), - "privacyPolicy": True, - "eula": True, - } - - # keeps in sync fields from example and this fixture - assert set(form) == set(AccountRequestInfo.model_json_schema()["example"]["form"]) - return form - - -@pytest.fixture -async def pre_registration_details_db_cleanup( - client: TestClient, -) -> AsyncGenerator[None]: - """Fixture to clean up pre-registration details AND orphan users created during tests.""" - - assert client.app - engine = get_asyncpg_engine(client.app) - - # Snapshot user IDs before the test body runs - async with engine.connect() as conn: - result = await conn.execute(sa.select(users.c.id)) - user_ids_before = {row.id for row in result} - - yield - - # Tear down - async with engine.connect() as conn: - # 1. Clean pre-registration details - await conn.execute(sa.delete(users_pre_registration_details)) - - # 2. Remove users created during the test body (orphans from create_user / new_user calls) - result = await conn.execute(sa.select(users.c.id)) - user_ids_after = {row.id for row in result} - orphan_ids = user_ids_after - user_ids_before - if orphan_ids: - await conn.execute(sa.delete(users).where(users.c.id.in_(orphan_ids))) - - await conn.commit() - - -@pytest.mark.acceptance_test("pre-registration in https://github.com/ITISFoundation/osparc-simcore/issues/5138") -@pytest.mark.parametrize( - "user_role", - [ - UserRole.PRODUCT_OWNER, - ], -) -async def test_search_and_pre_registration( - client: TestClient, - logged_user: UserInfoDict, - account_request_form: dict[str, Any], - pre_registration_details_db_cleanup: None, -): - assert client.app - - # NOTE: listing of user accounts drops nullable fields to avoid lengthy responses (even if they have no defaults) - # therefore they are reconstructed here from http response payloads - nullable_fields = {name: None for name, field in UserAccountGet.model_fields.items() if is_nullable(field)} - - # ONLY in `users` and NOT `users_pre_registration_details` - resp = await client.get("/v0/admin/user-accounts:search", params={"email": logged_user["email"]}) - assert resp.status == status.HTTP_200_OK - - found, _ = await assert_status(resp, status.HTTP_200_OK) - assert len(found) == 1 - - got = UserAccountGet.model_validate({**nullable_fields, **found[0]}) - expected = { - "first_name": logged_user.get("first_name"), - "last_name": logged_user.get("last_name"), - "email": logged_user["email"], - "institution": None, - "phone": logged_user.get("phone"), - "address": None, - "city": None, - "state": None, - "postal_code": None, - "country": None, - "extras": {}, - "registered": True, - "status": UserStatus.ACTIVE, - "user_id": logged_user["id"], - "user_name": logged_user["name"], - "user_primary_group_id": logged_user.get("primary_gid"), - } - assert got.model_dump(include=set(expected)) == expected - - # NOT in `users` and ONLY `users_pre_registration_details` - - # create pre-registration - resp = await client.post("/v0/admin/user-accounts:pre-register", json=account_request_form) - assert resp.status == status.HTTP_200_OK - - resp = await client.get( - "/v0/admin/user-accounts:search", - params={"email": account_request_form["email"]}, - ) - found, _ = await assert_status(resp, status.HTTP_200_OK) - assert len(found) == 1 - - got = UserAccountGet.model_validate({**nullable_fields, **found[0]}) - assert got.model_dump(include={"registered", "status"}) == { - "registered": False, - "status": None, - } - - # Emulating registration of pre-register user - new_user = await _auth_service.create_user( - client.app, - email=account_request_form["email"], - password=DEFAULT_TEST_PASSWORD, - status_upon_creation=UserStatus.ACTIVE, - expires_at=None, - ) - - resp = await client.get( - "/v0/admin/user-accounts:search", - params={"email": account_request_form["email"]}, - ) - found, _ = await assert_status(resp, status.HTTP_200_OK) - assert len(found) == 1 - - got = UserAccountGet.model_validate({**nullable_fields, **found[0]}) - assert got.model_dump(include={"registered", "status"}) == { - "registered": True, - "status": new_user["status"], - } - - -@pytest.mark.parametrize( - "user_role", - [ - UserRole.PRODUCT_OWNER, - ], -) -async def test_list_users_accounts( # noqa: PLR0915 - client: TestClient, - logged_user: UserInfoDict, - account_request_form: dict[str, Any], - faker: Faker, - product_name: ProductName, - pre_registration_details_db_cleanup: None, - mock_invitations_service_http_api: AioResponsesMock, - mock_notifications_preview_template: AsyncMock, -): - assert client.app - - # 1. Create several pre-registered users - pre_registered_users = [] - for _ in range(5): # Create 5 pre-registered users - form_data = account_request_form.copy() - form_data["firstName"] = faker.first_name() - form_data["lastName"] = faker.last_name() - form_data["email"] = faker.email() - - resp = await client.post( - "/v0/admin/user-accounts:pre-register", - json=form_data, - headers={X_PRODUCT_NAME_HEADER: product_name}, - ) - pre_registered_data, _ = await assert_status(resp, status.HTTP_200_OK) - pre_registered_users.append(pre_registered_data) - - # Verify all pre-registered users are in PENDING status - url = client.app.router["list_users_accounts"].url_for() - resp = await client.get(f"{url}?review_status=PENDING", headers={X_PRODUCT_NAME_HEADER: product_name}) - assert resp.status == status.HTTP_200_OK - response_json = await resp.json() - - # Parse response into Page[UserForAdminGet] model - page_model = Page[UserAccountGet].model_validate(response_json) - - # Access the items field from the paginated response - pending_users = [user for user in page_model.data if user.account_request_status == "PENDING"] - pending_emails = [user.email for user in pending_users] - - for pre_user in pre_registered_users: - assert pre_user["email"] in pending_emails - - # 2. Register one of the pre-registered users: approve + create account - registered_email = pre_registered_users[0]["email"] - - # First, preview approval to get the invitation URL - preview_url = client.app.router["preview_approval_user_account"].url_for() - resp = await client.post( - f"{preview_url}", - headers={X_PRODUCT_NAME_HEADER: product_name}, - json={ - "email": registered_email, - "invitation": {"trialAccountDays": 30}, - }, - ) - preview_data, _ = await assert_status(resp, status.HTTP_200_OK) - invitation_url = preview_data["invitationUrl"] - - # Then approve with the invitation URL - url = client.app.router["approve_user_account"].url_for() - resp = await client.post( - f"{url}", - headers={X_PRODUCT_NAME_HEADER: product_name}, - json={"email": registered_email, "invitationUrl": invitation_url}, - ) - await assert_status(resp, status.HTTP_204_NO_CONTENT) - - # Emulates user accepting invitation link - new_user = await _auth_service.create_user( - client.app, - email=registered_email, - password=DEFAULT_TEST_PASSWORD, - status_upon_creation=UserStatus.ACTIVE, - expires_at=None, - ) - assert new_user["status"] == UserStatus.ACTIVE - - # 3. Test filtering by status - # a. Check PENDING filter (should exclude the registered user) - url = client.app.router["list_users_accounts"].url_for() - resp = await client.get(f"{url}?review_status=PENDING", headers={X_PRODUCT_NAME_HEADER: product_name}) - assert resp.status == status.HTTP_200_OK - response_json = await resp.json() - pending_page = Page[UserAccountGet].model_validate(response_json) - - # The registered user should no longer be in pending status - pending_emails = [user.email for user in pending_page.data] - assert registered_email not in pending_emails - assert len(pending_emails) >= len(pre_registered_users) - 1 - - # b. Check REVIEWED users (should include the registered user) - resp = await client.get(f"{url}?review_status=REVIEWED", headers={X_PRODUCT_NAME_HEADER: product_name}) - assert resp.status == status.HTTP_200_OK - response_json = await resp.json() - reviewed_page = Page[UserAccountGet].model_validate(response_json) - - # Find the registered user in the reviewed users - active_user = next( - (user for user in reviewed_page.data if user.email == registered_email), - None, - ) - assert active_user is not None - assert active_user.account_request_status == "APPROVED" - assert active_user.status == UserStatus.ACTIVE - - # 4. Test pagination - # a. First page (limit 2) - resp = await client.get( - f"{url}", - params={"limit": 2, "offset": 0}, - headers={X_PRODUCT_NAME_HEADER: product_name}, - ) - assert resp.status == status.HTTP_200_OK - response_json = await resp.json() - page1 = Page[UserAccountGet].model_validate(response_json) - - assert len(page1.data) == 2 - assert page1.meta.limit == 2 - assert page1.meta.offset == 0 - assert page1.meta.total >= len(pre_registered_users) - - # b. Second page (limit 2) - resp = await client.get( - f"{url}", - params={"limit": 2, "offset": 2}, - headers={X_PRODUCT_NAME_HEADER: product_name}, - ) - assert resp.status == status.HTTP_200_OK - response_json = await resp.json() - page2 = Page[UserAccountGet].model_validate(response_json) - - assert len(page2.data) == 2 - assert page2.meta.limit == 2 - assert page2.meta.offset == 2 - - # Ensure page 1 and page 2 contain different items - page1_emails = [user.email for user in page1.data] - page2_emails = [user.email for user in page2.data] - assert not set(page1_emails).intersection(page2_emails) - - # 5. Combine status filter with pagination - resp = await client.get( - f"{url}", - params={"review_status": "PENDING", "limit": 2, "offset": 0}, - headers={X_PRODUCT_NAME_HEADER: product_name}, - ) - assert resp.status == status.HTTP_200_OK - response_json = await resp.json() - filtered_page = Page[UserAccountGet].model_validate(response_json) - - assert len(filtered_page.data) <= 2 - for user in filtered_page.data: - assert user.registered is False # Pending users are not registered - assert user.account_request_status == "PENDING" - - -@pytest.mark.parametrize( - "user_role", - [ - UserRole.PRODUCT_OWNER, - ], -) -async def test_reject_user_account( - client: TestClient, - logged_user: UserInfoDict, - account_request_form: dict[str, Any], - faker: Faker, - product_name: ProductName, - pre_registration_details_db_cleanup: None, - mock_notifications_send_message: AsyncMock, - mock_notifications_preview_template: AsyncMock, -): - assert client.app - - # 1. Create a pre-registered user - form_data = account_request_form.copy() - form_data["firstName"] = faker.first_name() - form_data["lastName"] = faker.last_name() - form_data["email"] = "some-reject-user@email.com" - - resp = await client.post( - "/v0/admin/user-accounts:pre-register", - json=form_data, - headers={X_PRODUCT_NAME_HEADER: product_name}, - ) - pre_registered_data, _ = await assert_status(resp, status.HTTP_200_OK) - pre_registered_email = pre_registered_data["email"] - - # 2. Verify the user is in PENDING status - url = client.app.router["list_users_accounts"].url_for() - resp = await client.get(f"{url}?review_status=PENDING", headers={X_PRODUCT_NAME_HEADER: product_name}) - data, _ = await assert_status(resp, status.HTTP_200_OK) - - pending_emails = [user["email"] for user in data if user["status"] is None] - assert pre_registered_email in pending_emails - - # 3. Preview the rejection to get message content - preview_url = client.app.router["preview_rejection_user_account"].url_for() - resp = await client.post( - f"{preview_url}", - headers={X_PRODUCT_NAME_HEADER: product_name}, - json={"email": pre_registered_email}, - ) - preview_data, _ = await assert_status(resp, status.HTTP_200_OK) - message_content = preview_data["messageContent"] - - # 4. Reject the pre-registered user with message content - url = client.app.router["reject_user_account"].url_for() - resp = await client.post( - f"{url}", - headers={X_PRODUCT_NAME_HEADER: product_name}, - json={ - "email": pre_registered_email, - "messageContent": message_content, - }, - ) - await assert_status(resp, status.HTTP_204_NO_CONTENT) - - # 5. Verify notification was sent - mock_notifications_send_message.assert_called_once() - call_kwargs = mock_notifications_send_message.call_args.kwargs - assert call_kwargs["product_name"] == product_name - assert call_kwargs["channel"] == Channel.email - - # 5. Verify the user is no longer in PENDING status - url = client.app.router["list_users_accounts"].url_for() - resp = await client.get(f"{url}?review_status=PENDING", headers={X_PRODUCT_NAME_HEADER: product_name}) - pending_data, _ = await assert_status(resp, status.HTTP_200_OK) - pending_emails = [user["email"] for user in pending_data] - assert pre_registered_email not in pending_emails - - # 6. Verify the user is now in REJECTED status - # First get user details to check status - resp = await client.get( - "/v0/admin/user-accounts:search", - params={"email": pre_registered_email}, - headers={X_PRODUCT_NAME_HEADER: product_name}, - ) - found, _ = await assert_status(resp, status.HTTP_200_OK) - assert len(found) == 1 - - # Check that account_request_status is REJECTED - user_data = found[0] - assert user_data["accountRequestStatus"] == "REJECTED" - assert user_data["accountRequestReviewedBy"] == logged_user["name"] - assert user_data["accountRequestReviewedAt"] is not None - - # 7. Verify that a rejected user cannot be approved - url = client.app.router["approve_user_account"].url_for() - resp = await client.post( - f"{url}", - headers={X_PRODUCT_NAME_HEADER: product_name}, - json={ - "email": pre_registered_email, - "invitationUrl": "https://osparc-simcore.test/#/registration?invitation=fake", - }, - ) - # Should fail as the account is already reviewed - assert resp.status == status.HTTP_400_BAD_REQUEST - - -@pytest.mark.parametrize( - "user_role", - [ - UserRole.PRODUCT_OWNER, - ], -) -async def test_approve_user_account_with_full_invitation_details( - client: TestClient, - logged_user: UserInfoDict, - account_request_form: dict[str, Any], - faker: Faker, - product_name: ProductName, - pre_registration_details_db_cleanup: None, - mock_invitations_service_http_api: AioResponsesMock, - mock_notifications_send_message: AsyncMock, - mock_notifications_preview_template: AsyncMock, -): - """Test approving user account with complete invitation details (trial days + credits)""" - assert client.app - - test_email = faker.email() - - # 1. Create a pre-registered user - form_data = account_request_form.copy() - form_data["firstName"] = faker.first_name() - form_data["lastName"] = faker.last_name() - form_data["email"] = test_email - - resp = await client.post( - "/v0/admin/user-accounts:pre-register", - json=form_data, - headers={X_PRODUCT_NAME_HEADER: product_name}, - ) - await assert_status(resp, status.HTTP_200_OK) - - # 2. Preview approval to get the invitation URL and message content - preview_url = client.app.router["preview_approval_user_account"].url_for() - resp = await client.post( - f"{preview_url}", - headers={X_PRODUCT_NAME_HEADER: product_name}, - json={ - "email": test_email, - "invitation": { - "trialAccountDays": 30, - "extraCreditsInUsd": 100.0, - }, - }, - ) - preview_data, _ = await assert_status(resp, status.HTTP_200_OK) - invitation_url = preview_data["invitationUrl"] - message_content = preview_data.get("messageContent") - - # 3. Approve the user with the invitation URL and message content - approve_payload: dict[str, Any] = { - "email": test_email, - "invitationUrl": invitation_url, - } - if message_content: - approve_payload["messageContent"] = message_content - - url = client.app.router["approve_user_account"].url_for() - resp = await client.post( - f"{url}", - headers={X_PRODUCT_NAME_HEADER: product_name}, - json=approve_payload, - ) - await assert_status(resp, status.HTTP_204_NO_CONTENT) - - # 4. Verify notification was sent if message_content was provided - if message_content: - mock_notifications_send_message.assert_called_once() - call_kwargs = mock_notifications_send_message.call_args.kwargs - assert call_kwargs["product_name"] == product_name - assert call_kwargs["channel"] == Channel.email - - # 5. Verify the user account status and invitation data in extras - resp = await client.get( - "/v0/admin/user-accounts:search", - params={"email": test_email}, - headers={X_PRODUCT_NAME_HEADER: product_name}, - ) - found, _ = await assert_status(resp, status.HTTP_200_OK) - assert len(found) == 1 - - user_data = found[0] - assert user_data["accountRequestStatus"] == "APPROVED" - assert user_data["accountRequestReviewedBy"] == logged_user["name"] - assert user_data["accountRequestReviewedAt"] is not None - - # 5. Verify invitation data is stored in extras - assert "invitation" in user_data["extras"] - invitation_data = user_data["extras"]["invitation"] - assert invitation_data["guest"] == test_email - assert invitation_data["issuer"] == str(logged_user["id"]) - assert invitation_data["trial_account_days"] == 30 - assert invitation_data["extra_credits_in_usd"] == 100.0 - assert invitation_data["product"] == product_name - - -@pytest.mark.parametrize( - "user_role", - [UserRole.PRODUCT_OWNER], -) -async def test_approve_user_account_with_trial_days_only( - client: TestClient, - logged_user: UserInfoDict, - account_request_form: dict[str, Any], - faker: Faker, - product_name: ProductName, - pre_registration_details_db_cleanup: None, - mock_invitations_service_http_api: AioResponsesMock, - mock_notifications_preview_template: AsyncMock, -): - """Test approving user account with only trial days""" - assert client.app - - test_email = faker.email() - - # 1. Create a pre-registered user - form_data = account_request_form.copy() - form_data["firstName"] = faker.first_name() - form_data["lastName"] = faker.last_name() - form_data["email"] = test_email - - resp = await client.post( - "/v0/admin/user-accounts:pre-register", - json=form_data, - headers={X_PRODUCT_NAME_HEADER: product_name}, - ) - await assert_status(resp, status.HTTP_200_OK) - - # 2. Preview approval to get the invitation URL - preview_url = client.app.router["preview_approval_user_account"].url_for() - resp = await client.post( - f"{preview_url}", - headers={X_PRODUCT_NAME_HEADER: product_name}, - json={ - "email": test_email, - "invitation": {"trialAccountDays": 15}, - }, - ) - preview_data, _ = await assert_status(resp, status.HTTP_200_OK) - invitation_url = preview_data["invitationUrl"] - - # 3. Approve the user with the invitation URL - url = client.app.router["approve_user_account"].url_for() - resp = await client.post( - f"{url}", - headers={X_PRODUCT_NAME_HEADER: product_name}, - json={"email": test_email, "invitationUrl": invitation_url}, - ) - await assert_status(resp, status.HTTP_204_NO_CONTENT) - - # 3. Verify invitation data in extras - resp = await client.get( - "/v0/admin/user-accounts:search", - params={"email": test_email}, - headers={X_PRODUCT_NAME_HEADER: product_name}, - ) - found, _ = await assert_status(resp, status.HTTP_200_OK) - user_data = found[0] - - assert "invitation" in user_data["extras"] - invitation_data = user_data["extras"]["invitation"] - assert invitation_data["trial_account_days"] == 15 - assert invitation_data["extra_credits_in_usd"] is None - - -@pytest.mark.parametrize( - "user_role", - [UserRole.PRODUCT_OWNER], -) -async def test_approve_user_account_with_credits_only( - client: TestClient, - logged_user: UserInfoDict, - account_request_form: dict[str, Any], - faker: Faker, - product_name: ProductName, - pre_registration_details_db_cleanup: None, - mock_invitations_service_http_api: AioResponsesMock, - mock_notifications_preview_template: AsyncMock, -): - """Test approving user account with only extra credits""" - assert client.app - - test_email = faker.email() - - # 1. Create a pre-registered user - form_data = account_request_form.copy() - form_data["firstName"] = faker.first_name() - form_data["lastName"] = faker.last_name() - form_data["email"] = test_email - - resp = await client.post( - "/v0/admin/user-accounts:pre-register", - json=form_data, - headers={X_PRODUCT_NAME_HEADER: product_name}, - ) - await assert_status(resp, status.HTTP_200_OK) - - # 2. Preview approval to get the invitation URL - preview_url = client.app.router["preview_approval_user_account"].url_for() - resp = await client.post( - f"{preview_url}", - headers={X_PRODUCT_NAME_HEADER: product_name}, - json={ - "email": test_email, - "invitation": {"extraCreditsInUsd": 50.0}, - }, - ) - preview_data, _ = await assert_status(resp, status.HTTP_200_OK) - invitation_url = preview_data["invitationUrl"] - - # 3. Approve the user with the invitation URL - url = client.app.router["approve_user_account"].url_for() - resp = await client.post( - f"{url}", - headers={X_PRODUCT_NAME_HEADER: product_name}, - json={"email": test_email, "invitationUrl": invitation_url}, - ) - await assert_status(resp, status.HTTP_204_NO_CONTENT) - - # 3. Verify invitation data in extras - resp = await client.get( - "/v0/admin/user-accounts:search", - params={"email": test_email}, - headers={X_PRODUCT_NAME_HEADER: product_name}, - ) - found, _ = await assert_status(resp, status.HTTP_200_OK) - user_data = found[0] - - assert "invitation" in user_data["extras"] - invitation_data = user_data["extras"]["invitation"] - assert invitation_data["trial_account_days"] is None - assert invitation_data["extra_credits_in_usd"] == 50.0 - - -@pytest.mark.parametrize( - "user_role", - [ - UserRole.PRODUCT_OWNER, - ], -) -async def test_approve_user_account_without_invitation_url_fails( - client: TestClient, - logged_user: UserInfoDict, - account_request_form: dict[str, Any], - faker: Faker, - product_name: ProductName, - pre_registration_details_db_cleanup: None, -): - """Test approving user account without invitationUrl is rejected (field required)""" - assert client.app - - test_email = faker.email() - - # 1. Create a pre-registered user - form_data = account_request_form.copy() - form_data["firstName"] = faker.first_name() - form_data["lastName"] = faker.last_name() - form_data["email"] = test_email - - resp = await client.post( - "/v0/admin/user-accounts:pre-register", - json=form_data, - headers={X_PRODUCT_NAME_HEADER: product_name}, - ) - await assert_status(resp, status.HTTP_200_OK) - - # 2. Attempt to approve without invitationUrl — should fail with 422 - url = client.app.router["approve_user_account"].url_for() - resp = await client.post( - f"{url}", - headers={X_PRODUCT_NAME_HEADER: product_name}, - json={"email": test_email}, - ) - await assert_status(resp, status.HTTP_422_UNPROCESSABLE_ENTITY) - - -@pytest.fixture -def mock_notifications_preview_template(mocker: MockerFixture) -> AsyncMock: - """Mock the notifications_service.preview_template to avoid RabbitMQ dependency.""" - - async def _fake_preview_template( - app, - *, - product_name, - ref, - context, - ) -> TemplatePreview: - first_name = context.get("user", {}).get("first_name", "User") - if ref.template_name == "account_approved": - invitation_url = context.get("link", "https://example.com") - trial_days = context.get("trial_account_days") - extra_credits = context.get("extra_credits") - body_parts = [f"

Dear {first_name},

", "

Your account has been approved!

"] - if trial_days: - body_parts.append(f"

Trial period: {trial_days} days

") - if extra_credits: - body_parts.append(f"

Extra credits: ${extra_credits}

") - body_parts.append(f'

Accept Invitation

') - return TemplatePreview( - ref=TemplateRef(channel=Channel.email, template_name="account_approved"), - message_content={ - "subject": "Your account request has been accepted", - "body_html": "\n".join(body_parts), - "body_text": f"Dear {first_name}, your account has been approved.", - }, - ) - if ref.template_name == "account_rejected": - return TemplatePreview( - ref=TemplateRef(channel=Channel.email, template_name="account_rejected"), - message_content={ - "subject": "Your account request has been denied", - "body_html": ( - f"

Dear {first_name},

" - "

We regret to inform you that your account request has been denied.

" - ), - "body_text": f"Dear {first_name}, your account request has been denied.", - }, - ) - msg = f"Unexpected template_name={ref.template_name}" - raise ValueError(msg) - - return mocker.patch( - "simcore_service_webserver.notifications.notifications_service.preview_template", - side_effect=_fake_preview_template, - ) - - -@pytest.mark.parametrize( - "user_role", - [UserRole.PRODUCT_OWNER], -) -async def test_preview_approval_user_account( - client: TestClient, - logged_user: UserInfoDict, - account_request_form: dict[str, Any], - faker: Faker, - product_name: ProductName, - pre_registration_details_db_cleanup: None, - mock_invitations_service_http_api: AioResponsesMock, - mock_notifications_preview_template: AsyncMock, -): - """Test previewing the approval notification for a pre-registered user account.""" - assert client.app - - test_email = faker.email() - - # 1. Create a pre-registered user - form_data = account_request_form.copy() - form_data["firstName"] = faker.first_name() - form_data["lastName"] = faker.last_name() - form_data["email"] = test_email - - resp = await client.post( - "/v0/admin/user-accounts:pre-register", - json=form_data, - headers={X_PRODUCT_NAME_HEADER: product_name}, - ) - await assert_status(resp, status.HTTP_200_OK) - - # 2. Preview approval with full invitation details - preview_payload = { - "email": test_email, - "invitation": { - "trialAccountDays": 30, - "extraCreditsInUsd": 100.0, - }, - } - - url = client.app.router["preview_approval_user_account"].url_for() - assert url.path == "/v0/admin/user-accounts:preview-approval" - - resp = await client.post( - f"{url}", - headers={X_PRODUCT_NAME_HEADER: product_name}, - json=preview_payload, - ) - data, _ = await assert_status(resp, status.HTTP_200_OK) - - preview_result = UserAccountPreviewApprovalGet.model_validate(data) - - # Verify response contains invitation_url and message_content - assert preview_result.invitation_url is not None - assert preview_result.message_content is not None - assert preview_result.message_content.subject is not None - assert preview_result.message_content.body_html is not None or preview_result.message_content.body_text is not None - - -@pytest.mark.parametrize( - "user_role", - [UserRole.PRODUCT_OWNER], -) -async def test_preview_approval_with_trial_days_only( - client: TestClient, - logged_user: UserInfoDict, - account_request_form: dict[str, Any], - faker: Faker, - product_name: ProductName, - pre_registration_details_db_cleanup: None, - mock_invitations_service_http_api: AioResponsesMock, - mock_notifications_preview_template: AsyncMock, -): - """Test previewing approval with only trial days.""" - assert client.app - - test_email = faker.email() - - # Create a pre-registered user - form_data = account_request_form.copy() - form_data["firstName"] = faker.first_name() - form_data["lastName"] = faker.last_name() - form_data["email"] = test_email - - resp = await client.post( - "/v0/admin/user-accounts:pre-register", - json=form_data, - headers={X_PRODUCT_NAME_HEADER: product_name}, - ) - await assert_status(resp, status.HTTP_200_OK) - - # Preview approval with only trial days - preview_payload = { - "email": test_email, - "invitation": { - "trialAccountDays": 15, - }, - } - - url = client.app.router["preview_approval_user_account"].url_for() - resp = await client.post( - f"{url}", - headers={X_PRODUCT_NAME_HEADER: product_name}, - json=preview_payload, - ) - data, _ = await assert_status(resp, status.HTTP_200_OK) - - preview_result = UserAccountPreviewApprovalGet.model_validate(data) - assert preview_result.invitation_url is not None - assert preview_result.message_content is not None - - -@pytest.mark.parametrize( - "user_role", - [UserRole.PRODUCT_OWNER], -) -async def test_preview_approval_with_credits_only( - client: TestClient, - logged_user: UserInfoDict, - account_request_form: dict[str, Any], - faker: Faker, - product_name: ProductName, - pre_registration_details_db_cleanup: None, - mock_invitations_service_http_api: AioResponsesMock, - mock_notifications_preview_template: AsyncMock, -): - """Test previewing approval with only extra credits.""" - assert client.app - - test_email = faker.email() - - # Create a pre-registered user - form_data = account_request_form.copy() - form_data["firstName"] = faker.first_name() - form_data["lastName"] = faker.last_name() - form_data["email"] = test_email - - resp = await client.post( - "/v0/admin/user-accounts:pre-register", - json=form_data, - headers={X_PRODUCT_NAME_HEADER: product_name}, - ) - await assert_status(resp, status.HTTP_200_OK) - - # Preview approval with only extra credits - preview_payload = { - "email": test_email, - "invitation": { - "extraCreditsInUsd": 50.0, - }, - } - - url = client.app.router["preview_approval_user_account"].url_for() - resp = await client.post( - f"{url}", - headers={X_PRODUCT_NAME_HEADER: product_name}, - json=preview_payload, - ) - data, _ = await assert_status(resp, status.HTTP_200_OK) - - preview_result = UserAccountPreviewApprovalGet.model_validate(data) - assert preview_result.invitation_url is not None - assert preview_result.message_content is not None - - -@pytest.mark.parametrize( - "user_role", - [UserRole.PRODUCT_OWNER], -) -async def test_preview_approval_for_nonexistent_user( - client: TestClient, - logged_user: UserInfoDict, - product_name: ProductName, - pre_registration_details_db_cleanup: None, - mock_invitations_service_http_api: AioResponsesMock, - mock_notifications_preview_template: AsyncMock, -): - """Test previewing approval for an email that has no pre-registration.""" - assert client.app - - preview_payload = { - "email": "nonexistent-user@example.com", - "invitation": { - "trialAccountDays": 30, - }, - } - - url = client.app.router["preview_approval_user_account"].url_for() - resp = await client.post( - f"{url}", - headers={X_PRODUCT_NAME_HEADER: product_name}, - json=preview_payload, - ) - # Nonexistent user triggers an error (bad request or not found) - assert resp.status in { - status.HTTP_200_OK, - status.HTTP_400_BAD_REQUEST, - status.HTTP_404_NOT_FOUND, - } - - -@pytest.mark.parametrize( - "user_role", - [UserRole.PRODUCT_OWNER], -) -async def test_preview_rejection_user_account( - client: TestClient, - logged_user: UserInfoDict, - account_request_form: dict[str, Any], - faker: Faker, - product_name: ProductName, - pre_registration_details_db_cleanup: None, - mock_notifications_preview_template: AsyncMock, -): - """Test previewing the rejection notification for a pre-registered user account.""" - assert client.app - - test_email = faker.email() - - # 1. Create a pre-registered user - form_data = account_request_form.copy() - form_data["firstName"] = faker.first_name() - form_data["lastName"] = faker.last_name() - form_data["email"] = test_email - - resp = await client.post( - "/v0/admin/user-accounts:pre-register", - json=form_data, - headers={X_PRODUCT_NAME_HEADER: product_name}, - ) - await assert_status(resp, status.HTTP_200_OK) - - # 2. Preview rejection - preview_payload = { - "email": test_email, - } - - url = client.app.router["preview_rejection_user_account"].url_for() - assert url.path == "/v0/admin/user-accounts:preview-rejection" - - resp = await client.post( - f"{url}", - headers={X_PRODUCT_NAME_HEADER: product_name}, - json=preview_payload, - ) - data, _ = await assert_status(resp, status.HTTP_200_OK) - - preview_result = UserAccountPreviewRejectionGet.model_validate(data) - - # Verify response contains message_content with rejection email content - assert preview_result.message_content is not None - assert preview_result.message_content.subject is not None - assert "denied" in preview_result.message_content.subject.lower() - assert preview_result.message_content.body_html is not None or preview_result.message_content.body_text is not None - - -@pytest.mark.parametrize( - "user_role", - [UserRole.PRODUCT_OWNER], -) -async def test_preview_rejection_for_nonexistent_user( - client: TestClient, - logged_user: UserInfoDict, - product_name: ProductName, - pre_registration_details_db_cleanup: None, - mock_notifications_preview_template: AsyncMock, -): - """Test previewing rejection for an email that has no pre-registration.""" - assert client.app - - preview_payload = { - "email": "nonexistent-user@example.com", - } - - url = client.app.router["preview_rejection_user_account"].url_for() - resp = await client.post( - f"{url}", - headers={X_PRODUCT_NAME_HEADER: product_name}, - json=preview_payload, - ) - # Should fail since the user doesn't exist - assert resp.status in {status.HTTP_404_NOT_FOUND, status.HTTP_500_INTERNAL_SERVER_ERROR} - - -@pytest.mark.parametrize( - "user_role,expected", - [ - (UserRole.ANONYMOUS, status.HTTP_401_UNAUTHORIZED), - *((role, status.HTTP_403_FORBIDDEN) for role in UserRole if UserRole.ANONYMOUS < role < UserRole.PRODUCT_OWNER), - (UserRole.PRODUCT_OWNER, status.HTTP_200_OK), - (UserRole.ADMIN, status.HTTP_200_OK), - ], -) -async def test_access_rights_on_preview_approval( - client: TestClient, - logged_user: UserInfoDict, - expected: HTTPStatus, - pre_registration_details_db_cleanup: None, -): - """Test that only PRODUCT_OWNER and ADMIN can access preview approval endpoint.""" - assert client.app - - url = client.app.router["preview_approval_user_account"].url_for() - assert url.path == "/v0/admin/user-accounts:preview-approval" - - resp = await client.post( - url.path, - json={ - "email": "test@example.com", - "invitation": {"trialAccountDays": 30}, - }, - ) - if expected in {status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN}: - await assert_status(resp, expected) - else: - # Authorized roles pass access control; may fail for other reasons (e.g. user not found) - assert resp.status not in {status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN} - - -@pytest.mark.parametrize( - "user_role,expected", - [ - (UserRole.ANONYMOUS, status.HTTP_401_UNAUTHORIZED), - *((role, status.HTTP_403_FORBIDDEN) for role in UserRole if UserRole.ANONYMOUS < role < UserRole.PRODUCT_OWNER), - (UserRole.PRODUCT_OWNER, status.HTTP_200_OK), - (UserRole.ADMIN, status.HTTP_200_OK), - ], -) -async def test_access_rights_on_preview_rejection( - client: TestClient, - logged_user: UserInfoDict, - expected: HTTPStatus, - pre_registration_details_db_cleanup: None, -): - """Test that only PRODUCT_OWNER and ADMIN can access preview rejection endpoint.""" - assert client.app - - url = client.app.router["preview_rejection_user_account"].url_for() - assert url.path == "/v0/admin/user-accounts:preview-rejection" - - resp = await client.post( - url.path, - json={"email": "test@example.com"}, - ) - if expected in {status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN}: - await assert_status(resp, expected) - else: - # Authorized roles pass access control; may fail for other reasons (e.g. user not found) - assert resp.status not in {status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN} - - -@pytest.mark.parametrize( - "user_role", - [UserRole.PRODUCT_OWNER], -) -async def test_create_user_auto_approves_pre_registration_with_recovery_metadata( - client: TestClient, - logged_user: UserInfoDict, - account_request_form: dict[str, Any], - product_name: ProductName, - pre_registration_details_db_cleanup: None, -): - """Test that link_and_update_user_from_pre_registration auto-reconciles PENDING - pre-registrations when the user has product access, and writes recovery metadata - into extras. - - SETUP: - - Pre-register a user via API (PENDING, with form extras) - - Create a new user with that email - - Add user to the product group - - Call link_and_update_user_from_pre_registration - - EXPECTED: - - Pre-registration status -> APPROVED - - user_id linked - - extras.recovery has source, confidence, executed_at, notes - - Original form extras preserved - """ - assert client.app - - test_email = account_request_form["email"] - - # 1. Pre-register via API -> creates PENDING record with form extras - resp = await client.post( - "/v0/admin/user-accounts:pre-register", - json=account_request_form, - headers={X_PRODUCT_NAME_HEADER: product_name}, - ) - pre_reg_data, _ = await assert_status(resp, status.HTTP_200_OK) - assert pre_reg_data["email"] == test_email - - # 2. Create user + add to product group + link pre-registration - # (simulating the real registration flow order: create user, add to group, then link) - from simcore_postgres_database.utils_users import UsersRepo # noqa: PLC0415 - - engine = get_asyncpg_engine(client.app) - repo = UsersRepo(engine) - - from simcore_service_webserver.security import security_service # noqa: PLC0415 - - new_user = await repo.new_user( - email=test_email, - password_hash=security_service.encrypt_password(DEFAULT_TEST_PASSWORD), - status=UserStatus.ACTIVE, - expires_at=None, - ) - - # Add user to product group (before link_and_update so reconciliation can trigger) - from simcore_service_webserver.groups import _groups_repository # noqa: PLC0415 - - await _groups_repository.auto_add_user_to_product_group( - client.app, - user_id=new_user.id, - product_name=product_name, - ) - - # 3. Link and reconcile - await repo.link_and_update_user_from_pre_registration( - new_user_id=new_user.id, - new_user_email=new_user.email, - ) - - # 4. Verify via API - resp = await client.get( - "/v0/admin/user-accounts:search", - params={"email": test_email}, - headers={X_PRODUCT_NAME_HEADER: product_name}, - ) - found, _ = await assert_status(resp, status.HTTP_200_OK) - assert len(found) == 1 - - user_data = found[0] - assert user_data["accountRequestStatus"] == "APPROVED" - assert user_data["registered"] is True - - # 5. Verify recovery metadata in extras - extras = user_data.get("extras", {}) - assert "recovery" in extras, f"Expected 'recovery' key in extras, got: {extras}" - recovery = extras["recovery"] - assert recovery["source"] == "runtime:link_and_update_user_from_pre_registration" - assert recovery["confidence"] in ("high", "medium") - assert recovery["executed_at"] is not None - assert "auto-reconciled" in recovery["notes"].lower() - - # 6. Verify original form extras are preserved (not overwritten) - assert "application" in extras or "description" in extras or "privacyPolicy" in extras - - -@pytest.mark.parametrize("user_role", [UserRole.PRODUCT_OWNER]) -async def test_list_users_accounts_unknown_product_override_returns_409( - client: TestClient, - logged_user: UserInfoDict, - product_name: ProductName, - pre_registration_details_db_cleanup: None, -): - """Passing an unknown product_name query override must yield 409 Conflict, not 404.""" - assert client.app - invalid_product_name = "nonexistent-product-xyz" - - url = client.app.router["list_users_accounts"].url_for() - assert url.path == "/v0/admin/user-accounts" - - resp = await client.get( - f"{url}?product_name={invalid_product_name}", - headers={X_PRODUCT_NAME_HEADER: product_name}, - ) - _, error = await assert_status(resp, status.HTTP_409_CONFLICT) - - error_model = ErrorGet.model_validate(error) - assert error_model.status == status.HTTP_409_CONFLICT - assert error_model.message == f"Invalid product '{invalid_product_name}'. The specified product does not exist." - - -@pytest.mark.parametrize("user_role", [UserRole.PRODUCT_OWNER]) -async def test_move_user_account_unknown_product_returns_409( - client: TestClient, - logged_user: UserInfoDict, - account_request_form: dict[str, Any], - product_name: ProductName, - pre_registration_details_db_cleanup: None, -): - """Passing an unknown new_product_name in the move endpoint must yield 409 Conflict.""" - assert client.app - invalid_product_name = "nonexistent-product-xyz" - - # 1. Create a pending pre-registration so the move operation can be attempted - resp = await client.post( - "/v0/admin/user-accounts:pre-register", - json=account_request_form, - headers={X_PRODUCT_NAME_HEADER: product_name}, - ) - pre_reg_data, _ = await assert_status(resp, status.HTTP_200_OK) - pre_registration_id: int = pre_reg_data["preRegistrationId"] - - # 2. Move towards a product that does not exist -> should be a conflict (409) - url = client.app.router["move_user_account"].url_for() - assert url.path == "/v0/admin/user-accounts:move" - - resp = await client.post( - f"{url}", - headers={X_PRODUCT_NAME_HEADER: product_name}, - json={ - "preRegistrationId": pre_registration_id, - "newProductName": invalid_product_name, - }, - ) - _, error = await assert_status(resp, status.HTTP_409_CONFLICT) - - error_model = ErrorGet.model_validate(error) - assert error_model.status == status.HTTP_409_CONFLICT - assert error_model.message == f"Invalid product '{invalid_product_name}'. The specified product does not exist." - - -@pytest.mark.parametrize("user_role", [UserRole.PRODUCT_OWNER]) -async def test_list_products_for_user_accounts_marks_current_product( - client: TestClient, - logged_user: UserInfoDict, - product_name: ProductName, - pre_registration_details_db_cleanup: None, -): - assert client.app - - url = client.app.router["list_products_for_user_accounts"].url_for() - assert url.path == "/v0/admin/products" - - resp = await client.get( - f"{url}", - headers={X_PRODUCT_NAME_HEADER: product_name}, - ) - data, _ = await assert_status(resp, status.HTTP_200_OK) - - options = TypeAdapter(list[UserAccountProductOptionGet]).validate_python(data) - current_options = [option for option in options if option.is_current] - - assert current_options - assert any(option.name == product_name for option in current_options) diff --git a/services/web/server/tests/unit/with_dbs/03/invitations/users_accounts_registration/conftest.py b/services/web/server/tests/unit/with_dbs/03/invitations/users_accounts_registration/conftest.py new file mode 100644 index 000000000000..70238e9b7292 --- /dev/null +++ b/services/web/server/tests/unit/with_dbs/03/invitations/users_accounts_registration/conftest.py @@ -0,0 +1,292 @@ +# pylint: disable=redefined-outer-name +# pylint: disable=too-many-arguments +# pylint: disable=unused-argument +# pylint: disable=unused-variable + +from collections.abc import AsyncGenerator, AsyncIterator +from typing import Any +from unittest.mock import AsyncMock + +import pytest +import sqlalchemy as sa +from aiohttp.test_utils import TestClient +from common_library.users_enums import UserRole, UserStatus +from faker import Faker +from models_library.api_schemas_webserver.auth import AccountRequestInfo +from models_library.groups import AccessRightsDict +from models_library.notifications import Channel +from models_library.products import ProductName +from pytest_mock import MockerFixture +from pytest_simcore.aioresponses_mocker import AioResponsesMock +from pytest_simcore.helpers.assert_checks import assert_status +from pytest_simcore.helpers.faker_factories import DEFAULT_TEST_PASSWORD +from pytest_simcore.helpers.monkeypatch_envs import setenvs_from_dict +from pytest_simcore.helpers.typing_env import EnvVarsDict +from pytest_simcore.helpers.webserver_login import UserInfoDict +from pytest_simcore.helpers.webserver_users import NewUser +from servicelib.aiohttp import status +from servicelib.rest_constants import X_PRODUCT_NAME_HEADER +from simcore_postgres_database.models.users import users +from simcore_postgres_database.models.users_details import ( + users_pre_registration_details, +) +from simcore_service_webserver.db.plugin import get_asyncpg_engine +from simcore_service_webserver.login import _auth_service +from simcore_service_webserver.models import PhoneNumberStr +from simcore_service_webserver.notifications._models import TemplatePreview, TemplateRef + + +@pytest.fixture +def app_environment(app_environment: EnvVarsDict, monkeypatch: pytest.MonkeyPatch) -> EnvVarsDict: + # disables GC and DB-listener + return app_environment | setenvs_from_dict( + monkeypatch, + { + "WEBSERVER_GARBAGE_COLLECTOR": "null", + "WEBSERVER_DB_LISTENER": "0", + }, + ) + + +@pytest.fixture +def mock_notifications_send_message(mocker: MockerFixture) -> AsyncMock: + """Mock the notifications_service.send_message to avoid RabbitMQ dependency.""" + return mocker.patch( + "simcore_service_webserver.notifications.notifications_service.send_message", + return_value=AsyncMock(), + ) + + +@pytest.fixture +async def support_user( + support_group_before_app_starts: dict, + client: TestClient, +) -> AsyncIterator[UserInfoDict]: + """Creates an active user that belongs to the product's support group.""" + async with NewUser( + user_data={ + "name": "support-user", + "status": UserStatus.ACTIVE.name, + "role": UserRole.USER.name, + }, + app=client.app, + ) as user_info: + # Add the user to the support group + assert client.app + + from simcore_service_webserver.groups import _groups_repository # noqa: PLC0415 + + # Now add user to support group with read-only access + await _groups_repository.add_new_user_in_group( + client.app, + group_id=support_group_before_app_starts["gid"], + new_user_id=user_info["id"], + access_rights=AccessRightsDict(read=True, write=False, delete=False), + ) + + yield user_info + + +@pytest.fixture +def account_request_form( + faker: Faker, + user_phone_number: PhoneNumberStr, +) -> dict[str, Any]: + # This is AccountRequestInfo.form + form = { + "firstName": faker.first_name(), + "lastName": faker.last_name(), + "email": faker.email(), + "phone": user_phone_number, + "company": faker.company(), + # billing info + "address": faker.address().replace("\n", ", "), + "city": faker.city(), + "postalCode": faker.postcode(), + "country": faker.country(), + # extras + "application": faker.word(), + "description": faker.sentence(), + "hear": faker.word(), + "privacyPolicy": True, + "eula": True, + } + + # keeps in sync fields from example and this fixture + assert set(form) == set(AccountRequestInfo.model_json_schema()["example"]["form"]) + return form + + +@pytest.fixture +async def pre_registration_details_db_cleanup( + client: TestClient, +) -> AsyncGenerator[None]: + """Fixture to clean up pre-registration details AND orphan users created during tests.""" + + assert client.app + engine = get_asyncpg_engine(client.app) + + # Snapshot user IDs before the test body runs + async with engine.connect() as conn: + result = await conn.execute(sa.select(users.c.id)) + user_ids_before = {row.id for row in result} + + yield + + # Tear down + async with engine.connect() as conn: + # 1. Clean pre-registration details + await conn.execute(sa.delete(users_pre_registration_details)) + + # 2. Remove users created during the test body (orphans from create_user / new_user calls) + result = await conn.execute(sa.select(users.c.id)) + user_ids_after = {row.id for row in result} + orphan_ids = user_ids_after - user_ids_before + if orphan_ids: + await conn.execute(sa.delete(users).where(users.c.id.in_(orphan_ids))) + + await conn.commit() + + +@pytest.fixture +async def seeded_user_accounts_for_registered_review_filters( + client: TestClient, + account_request_form: dict[str, Any], + faker: Faker, + product_name: ProductName, + mock_invitations_service_http_api: AioResponsesMock, + mock_notifications_preview_template: AsyncMock, +) -> dict[str, str]: + assert client.app + + async def _pre_register(email: str) -> None: + form_data = account_request_form.copy() + form_data["firstName"] = faker.first_name() + form_data["lastName"] = faker.last_name() + form_data["email"] = email + + resp = await client.post( + "/v0/admin/user-accounts:pre-register", + json=form_data, + headers={X_PRODUCT_NAME_HEADER: product_name}, + ) + await assert_status(resp, status.HTTP_200_OK) + + async def _approve(email: str) -> None: + preview_url = client.app.router["preview_approval_user_account"].url_for() + resp = await client.post( + f"{preview_url}", + headers={X_PRODUCT_NAME_HEADER: product_name}, + json={ + "email": email, + "invitation": {"trialAccountDays": 7}, + }, + ) + preview_data, _ = await assert_status(resp, status.HTTP_200_OK) + + approve_url = client.app.router["approve_user_account"].url_for() + resp = await client.post( + f"{approve_url}", + headers={X_PRODUCT_NAME_HEADER: product_name}, + json={"email": email, "invitationUrl": preview_data["invitationUrl"]}, + ) + await assert_status(resp, status.HTTP_204_NO_CONTENT) + + async def _reject(email: str) -> None: + reject_url = client.app.router["reject_user_account"].url_for() + resp = await client.post( + f"{reject_url}", + headers={X_PRODUCT_NAME_HEADER: product_name}, + json={"email": email}, + ) + await assert_status(resp, status.HTTP_204_NO_CONTENT) + + # pending + unregistered + pending_unregistered_email = faker.email() + await _pre_register(pending_unregistered_email) + + # pending + registered (anomaly): pre-register first, then create the user + pending_registered_email = faker.email() + await _pre_register(pending_registered_email) + await _auth_service.create_user( + client.app, + email=pending_registered_email, + password=DEFAULT_TEST_PASSWORD, + status_upon_creation=UserStatus.ACTIVE, + expires_at=None, + ) + + # reviewed + unregistered + reviewed_unregistered_email = faker.email() + await _pre_register(reviewed_unregistered_email) + await _reject(reviewed_unregistered_email) + + # reviewed + registered + reviewed_registered_email = faker.email() + await _pre_register(reviewed_registered_email) + await _approve(reviewed_registered_email) + await _auth_service.create_user( + client.app, + email=reviewed_registered_email, + password=DEFAULT_TEST_PASSWORD, + status_upon_creation=UserStatus.ACTIVE, + expires_at=None, + ) + + return { + "pending_registered": pending_registered_email, + "pending_unregistered": pending_unregistered_email, + "reviewed_registered": reviewed_registered_email, + "reviewed_unregistered": reviewed_unregistered_email, + } + + +@pytest.fixture +def mock_notifications_preview_template(mocker: MockerFixture) -> AsyncMock: + """Mock the notifications_service.preview_template to avoid RabbitMQ dependency.""" + + async def _fake_preview_template( + app, + *, + product_name, + ref, + context, + ) -> TemplatePreview: + first_name = context.get("user", {}).get("first_name", "User") + if ref.template_name == "account_approved": + invitation_url = context.get("link", "https://example.com") + trial_days = context.get("trial_account_days") + extra_credits = context.get("extra_credits") + body_parts = [f"

Dear {first_name},

", "

Your account has been approved!

"] + if trial_days: + body_parts.append(f"

Trial period: {trial_days} days

") + if extra_credits: + body_parts.append(f"

Extra credits: ${extra_credits}

") + body_parts.append(f'

Accept Invitation

') + return TemplatePreview( + ref=TemplateRef(channel=Channel.email, template_name="account_approved"), + message_content={ + "subject": "Your account request has been accepted", + "body_html": "\n".join(body_parts), + "body_text": f"Dear {first_name}, your account has been approved.", + }, + ) + if ref.template_name == "account_rejected": + return TemplatePreview( + ref=TemplateRef(channel=Channel.email, template_name="account_rejected"), + message_content={ + "subject": "Your account request has been denied", + "body_html": ( + f"

Dear {first_name},

" + "

We regret to inform you that your account request has been denied.

" + ), + "body_text": f"Dear {first_name}, your account request has been denied.", + }, + ) + msg = f"Unexpected template_name={ref.template_name}" + raise ValueError(msg) + + return mocker.patch( + "simcore_service_webserver.notifications.notifications_service.preview_template", + side_effect=_fake_preview_template, + ) diff --git a/services/web/server/tests/unit/with_dbs/03/invitations/users_accounts_registration/test_users_accounts_rest_registration_approve_reject.py b/services/web/server/tests/unit/with_dbs/03/invitations/users_accounts_registration/test_users_accounts_rest_registration_approve_reject.py new file mode 100644 index 000000000000..35eae4001e49 --- /dev/null +++ b/services/web/server/tests/unit/with_dbs/03/invitations/users_accounts_registration/test_users_accounts_rest_registration_approve_reject.py @@ -0,0 +1,511 @@ +# pylint: disable=redefined-outer-name +# pylint: disable=too-many-arguments +# pylint: disable=unused-argument +# pylint: disable=unused-variable + +from typing import Any +from unittest.mock import AsyncMock + +import pytest +from aiohttp.test_utils import TestClient +from common_library.users_enums import UserRole, UserStatus +from faker import Faker +from models_library.notifications import Channel +from models_library.products import ProductName +from pytest_simcore.aioresponses_mocker import AioResponsesMock +from pytest_simcore.helpers.assert_checks import assert_status +from pytest_simcore.helpers.faker_factories import DEFAULT_TEST_PASSWORD +from pytest_simcore.helpers.webserver_login import UserInfoDict +from servicelib.aiohttp import status +from servicelib.rest_constants import X_PRODUCT_NAME_HEADER +from simcore_service_webserver.db.plugin import get_asyncpg_engine + + +@pytest.fixture +def user_role() -> UserRole: + return UserRole.PRODUCT_OWNER + + +async def test_reject_user_account( + client: TestClient, + logged_user: UserInfoDict, + account_request_form: dict[str, Any], + faker: Faker, + product_name: ProductName, + pre_registration_details_db_cleanup: None, + mock_notifications_send_message: AsyncMock, + mock_notifications_preview_template: AsyncMock, +): + assert client.app + + # 1. Create a pre-registered user + form_data = account_request_form.copy() + form_data["firstName"] = faker.first_name() + form_data["lastName"] = faker.last_name() + form_data["email"] = "some-reject-user@email.com" + + url = client.app.router["pre_register_user_account"].url_for() + assert url.path == "/v0/admin/user-accounts:pre-register" + resp = await client.post( + f"{url}", + json=form_data, + headers={X_PRODUCT_NAME_HEADER: product_name}, + ) + pre_registered_data, _ = await assert_status(resp, status.HTTP_200_OK) + pre_registered_email = pre_registered_data["email"] + + # 2. Verify the user is in PENDING status + url = client.app.router["list_users_accounts"].url_for() + assert url.path == "/v0/admin/user-accounts" + resp = await client.get(f"{url}?review_status=PENDING", headers={X_PRODUCT_NAME_HEADER: product_name}) + data, _ = await assert_status(resp, status.HTTP_200_OK) + + pending_emails = [user["email"] for user in data if user["status"] is None] + assert pre_registered_email in pending_emails + + # 3. Preview the rejection to get message content + preview_url = client.app.router["preview_rejection_user_account"].url_for() + assert preview_url.path == "/v0/admin/user-accounts:preview-rejection" + resp = await client.post( + f"{preview_url}", + headers={X_PRODUCT_NAME_HEADER: product_name}, + json={"email": pre_registered_email}, + ) + preview_data, _ = await assert_status(resp, status.HTTP_200_OK) + message_content = preview_data["messageContent"] + + # 4. Reject the pre-registered user with message content + url = client.app.router["reject_user_account"].url_for() + assert url.path == "/v0/admin/user-accounts:reject" + resp = await client.post( + f"{url}", + headers={X_PRODUCT_NAME_HEADER: product_name}, + json={ + "email": pre_registered_email, + "messageContent": message_content, + }, + ) + await assert_status(resp, status.HTTP_204_NO_CONTENT) + + # 5. Verify notification was sent + mock_notifications_send_message.assert_called_once() + call_kwargs = mock_notifications_send_message.call_args.kwargs + assert call_kwargs["product_name"] == product_name + assert call_kwargs["channel"] == Channel.email + + # 5. Verify the user is no longer in PENDING status + url = client.app.router["list_users_accounts"].url_for() + assert url.path == "/v0/admin/user-accounts" + resp = await client.get(f"{url}?review_status=PENDING", headers={X_PRODUCT_NAME_HEADER: product_name}) + pending_data, _ = await assert_status(resp, status.HTTP_200_OK) + pending_emails = [user["email"] for user in pending_data] + assert pre_registered_email not in pending_emails + + # 6. Verify the user is now in REJECTED status + # First get user details to check status + url = client.app.router["search_user_accounts"].url_for() + assert url.path == "/v0/admin/user-accounts:search" + resp = await client.get( + f"{url}", + params={"email": pre_registered_email}, + headers={X_PRODUCT_NAME_HEADER: product_name}, + ) + found, _ = await assert_status(resp, status.HTTP_200_OK) + assert len(found) == 1 + + # Check that account_request_status is REJECTED + user_data = found[0] + assert user_data["accountRequestStatus"] == "REJECTED" + assert user_data["accountRequestReviewedBy"] == logged_user["name"] + assert user_data["accountRequestReviewedAt"] is not None + + # 7. Verify that a rejected user cannot be approved + url = client.app.router["approve_user_account"].url_for() + assert url.path == "/v0/admin/user-accounts:approve" + resp = await client.post( + f"{url}", + headers={X_PRODUCT_NAME_HEADER: product_name}, + json={ + "email": pre_registered_email, + "invitationUrl": "https://osparc-simcore.test/#/registration?invitation=fake", + }, + ) + # Should fail as the account is already reviewed + assert resp.status == status.HTTP_400_BAD_REQUEST + + +async def test_approve_user_account_with_full_invitation_details( + client: TestClient, + logged_user: UserInfoDict, + account_request_form: dict[str, Any], + faker: Faker, + product_name: ProductName, + pre_registration_details_db_cleanup: None, + mock_invitations_service_http_api: AioResponsesMock, + mock_notifications_send_message: AsyncMock, + mock_notifications_preview_template: AsyncMock, +): + """Test approving user account with complete invitation details (trial days + credits)""" + assert client.app + + test_email = faker.email() + + # 1. Create a pre-registered user + form_data = account_request_form.copy() + form_data["firstName"] = faker.first_name() + form_data["lastName"] = faker.last_name() + form_data["email"] = test_email + + url = client.app.router["pre_register_user_account"].url_for() + assert url.path == "/v0/admin/user-accounts:pre-register" + resp = await client.post( + f"{url}", + json=form_data, + headers={X_PRODUCT_NAME_HEADER: product_name}, + ) + await assert_status(resp, status.HTTP_200_OK) + + # 2. Preview approval to get the invitation URL and message content + preview_url = client.app.router["preview_approval_user_account"].url_for() + assert preview_url.path == "/v0/admin/user-accounts:preview-approval" + resp = await client.post( + f"{preview_url}", + headers={X_PRODUCT_NAME_HEADER: product_name}, + json={ + "email": test_email, + "invitation": { + "trialAccountDays": 30, + "extraCreditsInUsd": 100.0, + }, + }, + ) + preview_data, _ = await assert_status(resp, status.HTTP_200_OK) + invitation_url = preview_data["invitationUrl"] + message_content = preview_data.get("messageContent") + + # 3. Approve the user with the invitation URL and message content + approve_payload: dict[str, Any] = { + "email": test_email, + "invitationUrl": invitation_url, + } + if message_content: + approve_payload["messageContent"] = message_content + + url = client.app.router["approve_user_account"].url_for() + assert url.path == "/v0/admin/user-accounts:approve" + resp = await client.post( + f"{url}", + headers={X_PRODUCT_NAME_HEADER: product_name}, + json=approve_payload, + ) + await assert_status(resp, status.HTTP_204_NO_CONTENT) + + # 4. Verify notification was sent if message_content was provided + if message_content: + mock_notifications_send_message.assert_called_once() + call_kwargs = mock_notifications_send_message.call_args.kwargs + assert call_kwargs["product_name"] == product_name + assert call_kwargs["channel"] == Channel.email + + # 5. Verify the user account status and invitation data in extras + url = client.app.router["search_user_accounts"].url_for() + assert url.path == "/v0/admin/user-accounts:search" + resp = await client.get( + f"{url}", + params={"email": test_email}, + headers={X_PRODUCT_NAME_HEADER: product_name}, + ) + found, _ = await assert_status(resp, status.HTTP_200_OK) + assert len(found) == 1 + + user_data = found[0] + assert user_data["accountRequestStatus"] == "APPROVED" + assert user_data["accountRequestReviewedBy"] == logged_user["name"] + assert user_data["accountRequestReviewedAt"] is not None + + # 5. Verify invitation data is stored in extras + assert "invitation" in user_data["extras"] + invitation_data = user_data["extras"]["invitation"] + assert invitation_data["guest"] == test_email + assert invitation_data["issuer"] == str(logged_user["id"]) + assert invitation_data["trial_account_days"] == 30 + assert invitation_data["extra_credits_in_usd"] == 100.0 + assert invitation_data["product"] == product_name + + +async def test_approve_user_account_with_trial_days_only( + client: TestClient, + logged_user: UserInfoDict, + account_request_form: dict[str, Any], + faker: Faker, + product_name: ProductName, + pre_registration_details_db_cleanup: None, + mock_invitations_service_http_api: AioResponsesMock, + mock_notifications_preview_template: AsyncMock, +): + """Test approving user account with only trial days""" + assert client.app + + test_email = faker.email() + + # 1. Create a pre-registered user + form_data = account_request_form.copy() + form_data["firstName"] = faker.first_name() + form_data["lastName"] = faker.last_name() + form_data["email"] = test_email + + url = client.app.router["pre_register_user_account"].url_for() + assert url.path == "/v0/admin/user-accounts:pre-register" + resp = await client.post( + f"{url}", + json=form_data, + headers={X_PRODUCT_NAME_HEADER: product_name}, + ) + await assert_status(resp, status.HTTP_200_OK) + + # 2. Preview approval to get the invitation URL + preview_url = client.app.router["preview_approval_user_account"].url_for() + assert preview_url.path == "/v0/admin/user-accounts:preview-approval" + resp = await client.post( + f"{preview_url}", + headers={X_PRODUCT_NAME_HEADER: product_name}, + json={ + "email": test_email, + "invitation": {"trialAccountDays": 15}, + }, + ) + preview_data, _ = await assert_status(resp, status.HTTP_200_OK) + invitation_url = preview_data["invitationUrl"] + + # 3. Approve the user with the invitation URL + url = client.app.router["approve_user_account"].url_for() + assert url.path == "/v0/admin/user-accounts:approve" + resp = await client.post( + f"{url}", + headers={X_PRODUCT_NAME_HEADER: product_name}, + json={"email": test_email, "invitationUrl": invitation_url}, + ) + await assert_status(resp, status.HTTP_204_NO_CONTENT) + + # 3. Verify invitation data in extras + url = client.app.router["search_user_accounts"].url_for() + assert url.path == "/v0/admin/user-accounts:search" + resp = await client.get( + f"{url}", + params={"email": test_email}, + headers={X_PRODUCT_NAME_HEADER: product_name}, + ) + found, _ = await assert_status(resp, status.HTTP_200_OK) + user_data = found[0] + + assert "invitation" in user_data["extras"] + invitation_data = user_data["extras"]["invitation"] + assert invitation_data["trial_account_days"] == 15 + assert invitation_data["extra_credits_in_usd"] is None + + +async def test_approve_user_account_with_credits_only( + client: TestClient, + logged_user: UserInfoDict, + account_request_form: dict[str, Any], + faker: Faker, + product_name: ProductName, + pre_registration_details_db_cleanup: None, + mock_invitations_service_http_api: AioResponsesMock, + mock_notifications_preview_template: AsyncMock, +): + """Test approving user account with only extra credits""" + assert client.app + + test_email = faker.email() + + # 1. Create a pre-registered user + form_data = account_request_form.copy() + form_data["firstName"] = faker.first_name() + form_data["lastName"] = faker.last_name() + form_data["email"] = test_email + + url = client.app.router["pre_register_user_account"].url_for() + assert url.path == "/v0/admin/user-accounts:pre-register" + resp = await client.post( + f"{url}", + json=form_data, + headers={X_PRODUCT_NAME_HEADER: product_name}, + ) + await assert_status(resp, status.HTTP_200_OK) + + # 2. Preview approval to get the invitation URL + preview_url = client.app.router["preview_approval_user_account"].url_for() + assert preview_url.path == "/v0/admin/user-accounts:preview-approval" + resp = await client.post( + f"{preview_url}", + headers={X_PRODUCT_NAME_HEADER: product_name}, + json={ + "email": test_email, + "invitation": {"extraCreditsInUsd": 50.0}, + }, + ) + preview_data, _ = await assert_status(resp, status.HTTP_200_OK) + invitation_url = preview_data["invitationUrl"] + + # 3. Approve the user with the invitation URL + url = client.app.router["approve_user_account"].url_for() + assert url.path == "/v0/admin/user-accounts:approve" + resp = await client.post( + f"{url}", + headers={X_PRODUCT_NAME_HEADER: product_name}, + json={"email": test_email, "invitationUrl": invitation_url}, + ) + await assert_status(resp, status.HTTP_204_NO_CONTENT) + + # 3. Verify invitation data in extras + url = client.app.router["search_user_accounts"].url_for() + assert url.path == "/v0/admin/user-accounts:search" + resp = await client.get( + f"{url}", + params={"email": test_email}, + headers={X_PRODUCT_NAME_HEADER: product_name}, + ) + found, _ = await assert_status(resp, status.HTTP_200_OK) + user_data = found[0] + + assert "invitation" in user_data["extras"] + invitation_data = user_data["extras"]["invitation"] + assert invitation_data["trial_account_days"] is None + assert invitation_data["extra_credits_in_usd"] == 50.0 + + +async def test_approve_user_account_without_invitation_url_fails( + client: TestClient, + logged_user: UserInfoDict, + account_request_form: dict[str, Any], + faker: Faker, + product_name: ProductName, + pre_registration_details_db_cleanup: None, +): + """Test approving user account without invitationUrl is rejected (field required)""" + assert client.app + + test_email = faker.email() + + # 1. Create a pre-registered user + form_data = account_request_form.copy() + form_data["firstName"] = faker.first_name() + form_data["lastName"] = faker.last_name() + form_data["email"] = test_email + + url = client.app.router["pre_register_user_account"].url_for() + assert url.path == "/v0/admin/user-accounts:pre-register" + resp = await client.post( + f"{url}", + json=form_data, + headers={X_PRODUCT_NAME_HEADER: product_name}, + ) + await assert_status(resp, status.HTTP_200_OK) + + # 2. Attempt to approve without invitationUrl — should fail with 422 + url = client.app.router["approve_user_account"].url_for() + assert url.path == "/v0/admin/user-accounts:approve" + resp = await client.post( + f"{url}", + headers={X_PRODUCT_NAME_HEADER: product_name}, + json={"email": test_email}, + ) + await assert_status(resp, status.HTTP_422_UNPROCESSABLE_ENTITY) + + +async def test_create_user_auto_approves_pre_registration_with_recovery_metadata( + client: TestClient, + logged_user: UserInfoDict, + account_request_form: dict[str, Any], + product_name: ProductName, + pre_registration_details_db_cleanup: None, +): + """Test that link_and_update_user_from_pre_registration auto-reconciles PENDING + pre-registrations when the user has product access, and writes recovery metadata + into extras. + + SETUP: + - Pre-register a user via API (PENDING, with form extras) + - Create a new user with that email + - Add user to the product group + - Call link_and_update_user_from_pre_registration + + EXPECTED: + - Pre-registration status -> APPROVED + - user_id linked + - extras.recovery has source, confidence, executed_at, notes + - Original form extras preserved + """ + assert client.app + + test_email = account_request_form["email"] + + # 1. Pre-register via API -> creates PENDING record with form extras + url = client.app.router["pre_register_user_account"].url_for() + assert url.path == "/v0/admin/user-accounts:pre-register" + resp = await client.post( + f"{url}", + json=account_request_form, + headers={X_PRODUCT_NAME_HEADER: product_name}, + ) + pre_reg_data, _ = await assert_status(resp, status.HTTP_200_OK) + assert pre_reg_data["email"] == test_email + + # 2. Create user + add to product group + link pre-registration + # (simulating the real registration flow order: create user, add to group, then link) + from simcore_postgres_database.utils_users import UsersRepo # noqa: PLC0415 + + engine = get_asyncpg_engine(client.app) + repo = UsersRepo(engine) + + from simcore_service_webserver.security import security_service # noqa: PLC0415 + + new_user = await repo.new_user( + email=test_email, + password_hash=security_service.encrypt_password(DEFAULT_TEST_PASSWORD), + status=UserStatus.ACTIVE, + expires_at=None, + ) + + # Add user to product group (before link_and_update so reconciliation can trigger) + from simcore_service_webserver.groups import _groups_repository # noqa: PLC0415 + + await _groups_repository.auto_add_user_to_product_group( + client.app, + user_id=new_user.id, + product_name=product_name, + ) + + # 3. Link and reconcile + await repo.link_and_update_user_from_pre_registration( + new_user_id=new_user.id, + new_user_email=new_user.email, + ) + + # 4. Verify via API + url = client.app.router["search_user_accounts"].url_for() + assert url.path == "/v0/admin/user-accounts:search" + resp = await client.get( + f"{url}", + params={"email": test_email}, + headers={X_PRODUCT_NAME_HEADER: product_name}, + ) + found, _ = await assert_status(resp, status.HTTP_200_OK) + assert len(found) == 1 + + user_data = found[0] + assert user_data["accountRequestStatus"] == "APPROVED" + assert user_data["registered"] is True + + # 5. Verify recovery metadata in extras + extras = user_data.get("extras", {}) + assert "recovery" in extras, f"Expected 'recovery' key in extras, got: {extras}" + recovery = extras["recovery"] + assert recovery["source"] == "runtime:link_and_update_user_from_pre_registration" + assert recovery["confidence"] in ("high", "medium") + assert recovery["executed_at"] is not None + assert "auto-reconciled" in recovery["notes"].lower() + + # 6. Verify original form extras are preserved (not overwritten) + assert "application" in extras or "description" in extras or "privacyPolicy" in extras diff --git a/services/web/server/tests/unit/with_dbs/03/invitations/users_accounts_registration/test_users_accounts_rest_registration_list.py b/services/web/server/tests/unit/with_dbs/03/invitations/users_accounts_registration/test_users_accounts_rest_registration_list.py new file mode 100644 index 000000000000..34a068feb881 --- /dev/null +++ b/services/web/server/tests/unit/with_dbs/03/invitations/users_accounts_registration/test_users_accounts_rest_registration_list.py @@ -0,0 +1,345 @@ +# pylint: disable=redefined-outer-name +# pylint: disable=too-many-arguments +# pylint: disable=too-many-statements +# pylint: disable=unused-argument +# pylint: disable=unused-variable + +from typing import Any, Literal, TypedDict +from unittest.mock import AsyncMock + +import pytest +from aiohttp.test_utils import TestClient +from common_library.users_enums import UserRole, UserStatus +from faker import Faker +from models_library.api_schemas_webserver.users import ( + UserAccountGet, + UserAccountProductOptionGet, +) +from models_library.products import ProductName +from models_library.rest_error import ErrorGet +from models_library.rest_pagination import Page +from pydantic import TypeAdapter +from pytest_simcore.aioresponses_mocker import AioResponsesMock +from pytest_simcore.helpers.assert_checks import assert_status +from pytest_simcore.helpers.faker_factories import DEFAULT_TEST_PASSWORD +from pytest_simcore.helpers.webserver_login import UserInfoDict +from servicelib.aiohttp import status +from servicelib.rest_constants import X_PRODUCT_NAME_HEADER +from simcore_service_webserver.login import _auth_service + + +@pytest.fixture +def user_role() -> UserRole: + return UserRole.PRODUCT_OWNER + + +class SeededUserAccountsEmails(TypedDict): + pending_registered: str + pending_unregistered: str + reviewed_registered: str + reviewed_unregistered: str + + +class UserAccountsListQueryParams(TypedDict): + review_status: Literal["PENDING", "REVIEWED"] + registered: Literal["true", "false"] + limit: int + offset: int + + +async def test_list_users_accounts( # noqa: PLR0915 + client: TestClient, + logged_user: UserInfoDict, + account_request_form: dict[str, Any], + faker: Faker, + product_name: ProductName, + pre_registration_details_db_cleanup: None, + mock_invitations_service_http_api: AioResponsesMock, + mock_notifications_preview_template: AsyncMock, +): + assert client.app + + # 1. Create several pre-registered users + pre_register_url = client.app.router["pre_register_user_account"].url_for() + assert pre_register_url.path == "/v0/admin/user-accounts:pre-register" + + pre_registered_users = [] + for _ in range(5): # Create 5 pre-registered users + form_data = account_request_form.copy() + form_data["firstName"] = faker.first_name() + form_data["lastName"] = faker.last_name() + form_data["email"] = faker.email() + + resp = await client.post( + f"{pre_register_url}", + json=form_data, + headers={X_PRODUCT_NAME_HEADER: product_name}, + ) + pre_registered_data, _ = await assert_status(resp, status.HTTP_200_OK) + pre_registered_users.append(pre_registered_data) + + # Verify all pre-registered users are in PENDING status + url = client.app.router["list_users_accounts"].url_for() + assert url.path == "/v0/admin/user-accounts" + resp = await client.get(f"{url}?review_status=PENDING", headers={X_PRODUCT_NAME_HEADER: product_name}) + assert resp.status == status.HTTP_200_OK + response_json = await resp.json() + + # Parse response into Page[UserForAdminGet] model + page_model = Page[UserAccountGet].model_validate(response_json) + + # Access the items field from the paginated response + pending_users = [user for user in page_model.data if user.account_request_status == "PENDING"] + pending_emails = [user.email for user in pending_users] + + for pre_user in pre_registered_users: + assert pre_user["email"] in pending_emails + + # 2. Register one of the pre-registered users: approve + create account + registered_email = pre_registered_users[0]["email"] + + # First, preview approval to get the invitation URL + preview_url = client.app.router["preview_approval_user_account"].url_for() + assert preview_url.path == "/v0/admin/user-accounts:preview-approval" + resp = await client.post( + f"{preview_url}", + headers={X_PRODUCT_NAME_HEADER: product_name}, + json={ + "email": registered_email, + "invitation": {"trialAccountDays": 30}, + }, + ) + preview_data, _ = await assert_status(resp, status.HTTP_200_OK) + invitation_url = preview_data["invitationUrl"] + + # Then approve with the invitation URL + url = client.app.router["approve_user_account"].url_for() + assert url.path == "/v0/admin/user-accounts:approve" + resp = await client.post( + f"{url}", + headers={X_PRODUCT_NAME_HEADER: product_name}, + json={"email": registered_email, "invitationUrl": invitation_url}, + ) + await assert_status(resp, status.HTTP_204_NO_CONTENT) + + # Emulates user accepting invitation link + new_user = await _auth_service.create_user( + client.app, + email=registered_email, + password=DEFAULT_TEST_PASSWORD, + status_upon_creation=UserStatus.ACTIVE, + expires_at=None, + ) + assert new_user["status"] == UserStatus.ACTIVE + + # 3. Test filtering by status + # a. Check PENDING filter (should exclude the registered user) + url = client.app.router["list_users_accounts"].url_for() + assert url.path == "/v0/admin/user-accounts" + resp = await client.get(f"{url}?review_status=PENDING", headers={X_PRODUCT_NAME_HEADER: product_name}) + assert resp.status == status.HTTP_200_OK + response_json = await resp.json() + pending_page = Page[UserAccountGet].model_validate(response_json) + + # The registered user should no longer be in pending status + pending_emails = [user.email for user in pending_page.data] + assert registered_email not in pending_emails + assert len(pending_emails) >= len(pre_registered_users) - 1 + + # b. Check REVIEWED users (should include the registered user) + resp = await client.get(f"{url}?review_status=REVIEWED", headers={X_PRODUCT_NAME_HEADER: product_name}) + assert resp.status == status.HTTP_200_OK + response_json = await resp.json() + reviewed_page = Page[UserAccountGet].model_validate(response_json) + + # Find the registered user in the reviewed users + active_user = next( + (user for user in reviewed_page.data if user.email == registered_email), + None, + ) + assert active_user is not None + assert active_user.account_request_status == "APPROVED" + assert active_user.status == UserStatus.ACTIVE + + # 4. Test pagination + # a. First page (limit 2) + resp = await client.get( + f"{url}", + params={"limit": 2, "offset": 0}, + headers={X_PRODUCT_NAME_HEADER: product_name}, + ) + assert resp.status == status.HTTP_200_OK + response_json = await resp.json() + page1 = Page[UserAccountGet].model_validate(response_json) + + assert len(page1.data) == 2 + assert page1.meta.limit == 2 + assert page1.meta.offset == 0 + assert page1.meta.total >= len(pre_registered_users) + + # b. Second page (limit 2) + resp = await client.get( + f"{url}", + params={"limit": 2, "offset": 2}, + headers={X_PRODUCT_NAME_HEADER: product_name}, + ) + assert resp.status == status.HTTP_200_OK + response_json = await resp.json() + page2 = Page[UserAccountGet].model_validate(response_json) + + assert len(page2.data) == 2 + assert page2.meta.limit == 2 + assert page2.meta.offset == 2 + + # Ensure page 1 and page 2 contain different items + page1_emails = [user.email for user in page1.data] + page2_emails = [user.email for user in page2.data] + assert not set(page1_emails).intersection(page2_emails) + + # 5. Combine status filter with pagination + resp = await client.get( + f"{url}", + params={"review_status": "PENDING", "limit": 2, "offset": 0}, + headers={X_PRODUCT_NAME_HEADER: product_name}, + ) + assert resp.status == status.HTTP_200_OK + response_json = await resp.json() + filtered_page = Page[UserAccountGet].model_validate(response_json) + + assert len(filtered_page.data) <= 2 + for user in filtered_page.data: + assert user.registered is False # Pending users are not registered + assert user.account_request_status == "PENDING" + + +@pytest.mark.parametrize( + "params,expected_email_key,expected_registered,expected_review_statuses", + [ + ( + {"review_status": "PENDING", "registered": "true", "limit": 50, "offset": 0}, + "pending_registered", + True, + {"PENDING"}, + ), + ( + {"review_status": "PENDING", "registered": "false", "limit": 50, "offset": 0}, + "pending_unregistered", + False, + {"PENDING"}, + ), + ( + {"review_status": "REVIEWED", "registered": "true", "limit": 50, "offset": 0}, + "reviewed_registered", + True, + {"APPROVED", "REJECTED"}, + ), + ( + {"review_status": "REVIEWED", "registered": "false", "limit": 50, "offset": 0}, + "reviewed_unregistered", + False, + {"APPROVED", "REJECTED"}, + ), + ], + ids=[ + "pending-registered", + "pending-unregistered", + "reviewed-registered", + "reviewed-unregistered", + ], +) +async def test_list_users_accounts_with_review_status_and_registered_filters( + client: TestClient, + user_role, + logged_user: UserInfoDict, + product_name: ProductName, + pre_registration_details_db_cleanup: None, + seeded_user_accounts_for_registered_review_filters: SeededUserAccountsEmails, + params: UserAccountsListQueryParams, + expected_email_key: str, + expected_registered: bool, + expected_review_statuses: set[str], +): + assert client.app + + list_url = client.app.router["list_users_accounts"].url_for() + assert list_url.path == "/v0/admin/user-accounts" + query_params: dict[str, str | int] = { + "review_status": params["review_status"], + "registered": params["registered"], + "limit": params["limit"], + "offset": params["offset"], + } + expected_emails = {seeded_user_accounts_for_registered_review_filters[expected_email_key]} + resp = await client.get( + f"{list_url}", + params=query_params, + headers={X_PRODUCT_NAME_HEADER: product_name}, + ) + assert resp.status == status.HTTP_200_OK + payload = await resp.json() + page = Page[UserAccountGet].model_validate(payload) + + returned_emails = {u.email for u in page.data} + assert expected_emails.issubset(returned_emails) + assert all(u.registered is expected_registered for u in page.data) + assert all(u.account_request_status in expected_review_statuses for u in page.data) + + # The total count must come from the filtered DB result, not from page length. + total_with_large_page = page.meta.total + resp = await client.get( + f"{list_url}", + params={**query_params, "limit": 1, "offset": 0}, + headers={X_PRODUCT_NAME_HEADER: product_name}, + ) + assert resp.status == status.HTTP_200_OK + paged_payload = await resp.json() + paged = Page[UserAccountGet].model_validate(paged_payload) + assert paged.meta.total == total_with_large_page + + +async def test_list_users_accounts_unknown_product_override_returns_409( + client: TestClient, + logged_user: UserInfoDict, + product_name: ProductName, + pre_registration_details_db_cleanup: None, +): + """Passing an unknown product_name query override must yield 409 Conflict, not 404.""" + assert client.app + invalid_product_name = "nonexistent-product-xyz" + + url = client.app.router["list_users_accounts"].url_for() + assert url.path == "/v0/admin/user-accounts" + + resp = await client.get( + f"{url}?product_name={invalid_product_name}", + headers={X_PRODUCT_NAME_HEADER: product_name}, + ) + _, error = await assert_status(resp, status.HTTP_409_CONFLICT) + + error_model = ErrorGet.model_validate(error) + assert error_model.status == status.HTTP_409_CONFLICT + assert error_model.message == f"Invalid product '{invalid_product_name}'. The specified product does not exist." + + +async def test_list_products_for_user_accounts_marks_current_product( + client: TestClient, + logged_user: UserInfoDict, + product_name: ProductName, + pre_registration_details_db_cleanup: None, +): + assert client.app + + url = client.app.router["list_products_for_user_accounts"].url_for() + assert url.path == "/v0/admin/products" + + resp = await client.get( + f"{url}", + headers={X_PRODUCT_NAME_HEADER: product_name}, + ) + data, _ = await assert_status(resp, status.HTTP_200_OK) + + options = TypeAdapter(list[UserAccountProductOptionGet]).validate_python(data) + current_options = [option for option in options if option.is_current] + + assert current_options + assert any(option.name == product_name for option in current_options) diff --git a/services/web/server/tests/unit/with_dbs/03/invitations/users_accounts_registration/test_users_accounts_rest_registration_move.py b/services/web/server/tests/unit/with_dbs/03/invitations/users_accounts_registration/test_users_accounts_rest_registration_move.py new file mode 100644 index 000000000000..c0f07187b03c --- /dev/null +++ b/services/web/server/tests/unit/with_dbs/03/invitations/users_accounts_registration/test_users_accounts_rest_registration_move.py @@ -0,0 +1,62 @@ +# pylint: disable=redefined-outer-name +# pylint: disable=too-many-arguments +# pylint: disable=unused-argument +# pylint: disable=unused-variable + +from typing import Any + +import pytest +from aiohttp.test_utils import TestClient +from common_library.users_enums import UserRole +from models_library.products import ProductName +from models_library.rest_error import ErrorGet +from pytest_simcore.helpers.assert_checks import assert_status +from pytest_simcore.helpers.webserver_login import UserInfoDict +from servicelib.aiohttp import status +from servicelib.rest_constants import X_PRODUCT_NAME_HEADER + + +@pytest.fixture +def user_role() -> UserRole: + return UserRole.PRODUCT_OWNER + + +async def test_move_user_account_unknown_product_returns_409( + client: TestClient, + logged_user: UserInfoDict, + account_request_form: dict[str, Any], + product_name: ProductName, + pre_registration_details_db_cleanup: None, +): + """Passing an unknown new_product_name in the move endpoint must yield 409 Conflict.""" + assert client.app + invalid_product_name = "nonexistent-product-xyz" + + # 1. Create a pending pre-registration so the move operation can be attempted + url = client.app.router["pre_register_user_account"].url_for() + assert url.path == "/v0/admin/user-accounts:pre-register" + resp = await client.post( + f"{url}", + json=account_request_form, + headers={X_PRODUCT_NAME_HEADER: product_name}, + ) + pre_reg_data, _ = await assert_status(resp, status.HTTP_200_OK) + pre_registration_id: int = pre_reg_data["preRegistrationId"] + + # 2. Move towards a product that does not exist -> should be a conflict (409) + url = client.app.router["move_user_account"].url_for() + assert url.path == "/v0/admin/user-accounts:move" + + resp = await client.post( + f"{url}", + headers={X_PRODUCT_NAME_HEADER: product_name}, + json={ + "preRegistrationId": pre_registration_id, + "newProductName": invalid_product_name, + }, + ) + _, error = await assert_status(resp, status.HTTP_409_CONFLICT) + + error_model = ErrorGet.model_validate(error) + assert error_model.status == status.HTTP_409_CONFLICT + assert error_model.message == f"Invalid product '{invalid_product_name}'. The specified product does not exist." diff --git a/services/web/server/tests/unit/with_dbs/03/invitations/users_accounts_registration/test_users_accounts_rest_registration_preview.py b/services/web/server/tests/unit/with_dbs/03/invitations/users_accounts_registration/test_users_accounts_rest_registration_preview.py new file mode 100644 index 000000000000..bb581587db19 --- /dev/null +++ b/services/web/server/tests/unit/with_dbs/03/invitations/users_accounts_registration/test_users_accounts_rest_registration_preview.py @@ -0,0 +1,368 @@ +# pylint: disable=redefined-outer-name +# pylint: disable=too-many-arguments +# pylint: disable=unused-argument +# pylint: disable=unused-variable + +from http import HTTPStatus +from typing import Any +from unittest.mock import AsyncMock + +import pytest +from aiohttp.test_utils import TestClient +from common_library.users_enums import UserRole +from faker import Faker +from models_library.api_schemas_webserver.users import ( + UserAccountPreviewApprovalGet, + UserAccountPreviewRejectionGet, +) +from models_library.products import ProductName +from pytest_simcore.aioresponses_mocker import AioResponsesMock +from pytest_simcore.helpers.assert_checks import assert_status +from pytest_simcore.helpers.webserver_login import UserInfoDict +from servicelib.aiohttp import status +from servicelib.rest_constants import X_PRODUCT_NAME_HEADER + + +@pytest.fixture +def user_role() -> UserRole: + return UserRole.PRODUCT_OWNER + + +async def test_preview_approval_user_account( + client: TestClient, + logged_user: UserInfoDict, + account_request_form: dict[str, Any], + faker: Faker, + product_name: ProductName, + pre_registration_details_db_cleanup: None, + mock_invitations_service_http_api: AioResponsesMock, + mock_notifications_preview_template: AsyncMock, +): + """Test previewing the approval notification for a pre-registered user account.""" + assert client.app + + test_email = faker.email() + + # 1. Create a pre-registered user + form_data = account_request_form.copy() + form_data["firstName"] = faker.first_name() + form_data["lastName"] = faker.last_name() + form_data["email"] = test_email + + pre_register_url = client.app.router["pre_register_user_account"].url_for() + assert pre_register_url.path == "/v0/admin/user-accounts:pre-register" + resp = await client.post( + f"{pre_register_url}", + json=form_data, + headers={X_PRODUCT_NAME_HEADER: product_name}, + ) + await assert_status(resp, status.HTTP_200_OK) + + # 2. Preview approval with full invitation details + preview_payload = { + "email": test_email, + "invitation": { + "trialAccountDays": 30, + "extraCreditsInUsd": 100.0, + }, + } + + url = client.app.router["preview_approval_user_account"].url_for() + assert url.path == "/v0/admin/user-accounts:preview-approval" + + resp = await client.post( + f"{url}", + headers={X_PRODUCT_NAME_HEADER: product_name}, + json=preview_payload, + ) + data, _ = await assert_status(resp, status.HTTP_200_OK) + + preview_result = UserAccountPreviewApprovalGet.model_validate(data) + + # Verify response contains invitation_url and message_content + assert preview_result.invitation_url is not None + assert preview_result.message_content is not None + assert preview_result.message_content.subject is not None + assert preview_result.message_content.body_html is not None or preview_result.message_content.body_text is not None + + +async def test_preview_approval_with_trial_days_only( + client: TestClient, + logged_user: UserInfoDict, + account_request_form: dict[str, Any], + faker: Faker, + product_name: ProductName, + pre_registration_details_db_cleanup: None, + mock_invitations_service_http_api: AioResponsesMock, + mock_notifications_preview_template: AsyncMock, +): + """Test previewing approval with only trial days.""" + assert client.app + + test_email = faker.email() + + # Create a pre-registered user + form_data = account_request_form.copy() + form_data["firstName"] = faker.first_name() + form_data["lastName"] = faker.last_name() + form_data["email"] = test_email + + pre_register_url = client.app.router["pre_register_user_account"].url_for() + assert pre_register_url.path == "/v0/admin/user-accounts:pre-register" + resp = await client.post( + f"{pre_register_url}", + json=form_data, + headers={X_PRODUCT_NAME_HEADER: product_name}, + ) + await assert_status(resp, status.HTTP_200_OK) + + # Preview approval with only trial days + preview_payload = { + "email": test_email, + "invitation": { + "trialAccountDays": 15, + }, + } + + url = client.app.router["preview_approval_user_account"].url_for() + assert url.path == "/v0/admin/user-accounts:preview-approval" + resp = await client.post( + f"{url}", + headers={X_PRODUCT_NAME_HEADER: product_name}, + json=preview_payload, + ) + data, _ = await assert_status(resp, status.HTTP_200_OK) + + preview_result = UserAccountPreviewApprovalGet.model_validate(data) + assert preview_result.invitation_url is not None + assert preview_result.message_content is not None + + +async def test_preview_approval_with_credits_only( + client: TestClient, + logged_user: UserInfoDict, + account_request_form: dict[str, Any], + faker: Faker, + product_name: ProductName, + pre_registration_details_db_cleanup: None, + mock_invitations_service_http_api: AioResponsesMock, + mock_notifications_preview_template: AsyncMock, +): + """Test previewing approval with only extra credits.""" + assert client.app + + test_email = faker.email() + + # Create a pre-registered user + form_data = account_request_form.copy() + form_data["firstName"] = faker.first_name() + form_data["lastName"] = faker.last_name() + form_data["email"] = test_email + + pre_register_url = client.app.router["pre_register_user_account"].url_for() + assert pre_register_url.path == "/v0/admin/user-accounts:pre-register" + resp = await client.post( + f"{pre_register_url}", + json=form_data, + headers={X_PRODUCT_NAME_HEADER: product_name}, + ) + await assert_status(resp, status.HTTP_200_OK) + + # Preview approval with only extra credits + preview_payload = { + "email": test_email, + "invitation": { + "extraCreditsInUsd": 50.0, + }, + } + + url = client.app.router["preview_approval_user_account"].url_for() + assert url.path == "/v0/admin/user-accounts:preview-approval" + resp = await client.post( + f"{url}", + headers={X_PRODUCT_NAME_HEADER: product_name}, + json=preview_payload, + ) + data, _ = await assert_status(resp, status.HTTP_200_OK) + + preview_result = UserAccountPreviewApprovalGet.model_validate(data) + assert preview_result.invitation_url is not None + assert preview_result.message_content is not None + + +async def test_preview_approval_for_nonexistent_user( + client: TestClient, + logged_user: UserInfoDict, + product_name: ProductName, + pre_registration_details_db_cleanup: None, + mock_invitations_service_http_api: AioResponsesMock, + mock_notifications_preview_template: AsyncMock, +): + """Test previewing approval for an email that has no pre-registration.""" + assert client.app + + preview_payload = { + "email": "nonexistent-user@example.com", + "invitation": { + "trialAccountDays": 30, + }, + } + + url = client.app.router["preview_approval_user_account"].url_for() + assert url.path == "/v0/admin/user-accounts:preview-approval" + resp = await client.post( + f"{url}", + headers={X_PRODUCT_NAME_HEADER: product_name}, + json=preview_payload, + ) + # Nonexistent user triggers an error (bad request or not found) + _, error = await assert_status(resp, status.HTTP_400_BAD_REQUEST) + + error_message = error.get("message") or error.get("error") or error.get("detail") + assert error_message is not None + assert "pending registration request" in error_message.lower() + + +async def test_preview_rejection_user_account( + client: TestClient, + logged_user: UserInfoDict, + account_request_form: dict[str, Any], + faker: Faker, + product_name: ProductName, + pre_registration_details_db_cleanup: None, + mock_notifications_preview_template: AsyncMock, +): + """Test previewing the rejection notification for a pre-registered user account.""" + assert client.app + + test_email = faker.email() + + # 1. Create a pre-registered user + form_data = account_request_form.copy() + form_data["firstName"] = faker.first_name() + form_data["lastName"] = faker.last_name() + form_data["email"] = test_email + + pre_register_url = client.app.router["pre_register_user_account"].url_for() + assert pre_register_url.path == "/v0/admin/user-accounts:pre-register" + resp = await client.post( + f"{pre_register_url}", + json=form_data, + headers={X_PRODUCT_NAME_HEADER: product_name}, + ) + await assert_status(resp, status.HTTP_200_OK) + + # 2. Preview rejection + preview_payload = { + "email": test_email, + } + + url = client.app.router["preview_rejection_user_account"].url_for() + assert url.path == "/v0/admin/user-accounts:preview-rejection" + + resp = await client.post( + f"{url}", + headers={X_PRODUCT_NAME_HEADER: product_name}, + json=preview_payload, + ) + data, _ = await assert_status(resp, status.HTTP_200_OK) + + preview_result = UserAccountPreviewRejectionGet.model_validate(data) + + # Verify response contains message_content with rejection email content + assert preview_result.message_content is not None + assert preview_result.message_content.subject is not None + assert "denied" in preview_result.message_content.subject.lower() + assert preview_result.message_content.body_html is not None or preview_result.message_content.body_text is not None + + +async def test_preview_rejection_for_nonexistent_user( + client: TestClient, + logged_user: UserInfoDict, + product_name: ProductName, + pre_registration_details_db_cleanup: None, + mock_notifications_preview_template: AsyncMock, +): + """Test previewing rejection for an email that has no pre-registration.""" + assert client.app + + preview_payload = { + "email": "nonexistent-user@example.com", + } + + url = client.app.router["preview_rejection_user_account"].url_for() + assert url.path == "/v0/admin/user-accounts:preview-rejection" + resp = await client.post( + f"{url}", + headers={X_PRODUCT_NAME_HEADER: product_name}, + json=preview_payload, + ) + # A missing pre-registration must be reported as a client error, not as an unhandled 500. + await assert_status(resp, status.HTTP_400_BAD_REQUEST) + + +@pytest.mark.parametrize( + "user_role,expected", + [ + (UserRole.ANONYMOUS, status.HTTP_401_UNAUTHORIZED), + *((role, status.HTTP_403_FORBIDDEN) for role in UserRole if UserRole.ANONYMOUS < role < UserRole.PRODUCT_OWNER), + (UserRole.PRODUCT_OWNER, status.HTTP_200_OK), + (UserRole.ADMIN, status.HTTP_200_OK), + ], +) +async def test_access_rights_on_preview_approval( + client: TestClient, + logged_user: UserInfoDict, + expected: HTTPStatus, + pre_registration_details_db_cleanup: None, +): + """Test that only PRODUCT_OWNER and ADMIN can access preview approval endpoint.""" + assert client.app + + url = client.app.router["preview_approval_user_account"].url_for() + assert url.path == "/v0/admin/user-accounts:preview-approval" + + resp = await client.post( + url.path, + json={ + "email": "test@example.com", + "invitation": {"trialAccountDays": 30}, + }, + ) + if expected in {status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN}: + await assert_status(resp, expected) + else: + # Authorized roles pass access control; may fail for other reasons (e.g. user not found) + assert resp.status not in {status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN} + + +@pytest.mark.parametrize( + "user_role,expected", + [ + (UserRole.ANONYMOUS, status.HTTP_401_UNAUTHORIZED), + *((role, status.HTTP_403_FORBIDDEN) for role in UserRole if UserRole.ANONYMOUS < role < UserRole.PRODUCT_OWNER), + (UserRole.PRODUCT_OWNER, status.HTTP_200_OK), + (UserRole.ADMIN, status.HTTP_200_OK), + ], +) +async def test_access_rights_on_preview_rejection( + client: TestClient, + logged_user: UserInfoDict, + expected: HTTPStatus, + pre_registration_details_db_cleanup: None, +): + """Test that only PRODUCT_OWNER and ADMIN can access preview rejection endpoint.""" + assert client.app + + url = client.app.router["preview_rejection_user_account"].url_for() + assert url.path == "/v0/admin/user-accounts:preview-rejection" + + resp = await client.post( + url.path, + json={"email": "test@example.com"}, + ) + if expected in {status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN}: + await assert_status(resp, expected) + else: + # Authorized roles pass access control; may fail for other reasons (e.g. user not found) + assert resp.status not in {status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN} diff --git a/services/web/server/tests/unit/with_dbs/03/invitations/users_accounts_registration/test_users_accounts_rest_registration_search.py b/services/web/server/tests/unit/with_dbs/03/invitations/users_accounts_registration/test_users_accounts_rest_registration_search.py new file mode 100644 index 000000000000..95b46a19668a --- /dev/null +++ b/services/web/server/tests/unit/with_dbs/03/invitations/users_accounts_registration/test_users_accounts_rest_registration_search.py @@ -0,0 +1,154 @@ +# pylint: disable=redefined-outer-name +# pylint: disable=too-many-arguments +# pylint: disable=unused-argument +# pylint: disable=unused-variable + +from http import HTTPStatus +from typing import Any + +import pytest +from aiohttp.test_utils import TestClient +from common_library.pydantic_fields_extension import is_nullable +from common_library.users_enums import UserRole, UserStatus +from models_library.api_schemas_webserver.users import UserAccountGet +from pytest_simcore.helpers.assert_checks import assert_status +from pytest_simcore.helpers.faker_factories import DEFAULT_TEST_PASSWORD +from pytest_simcore.helpers.webserver_login import UserInfoDict +from servicelib.aiohttp import status +from simcore_service_webserver.login import _auth_service + + +@pytest.fixture +def user_role() -> UserRole: + return UserRole.PRODUCT_OWNER + + +@pytest.mark.parametrize( + "user_role,expected", + [ + (UserRole.ANONYMOUS, status.HTTP_401_UNAUTHORIZED), + *((role, status.HTTP_403_FORBIDDEN) for role in UserRole if UserRole.ANONYMOUS < role < UserRole.PRODUCT_OWNER), + (UserRole.PRODUCT_OWNER, status.HTTP_200_OK), + (UserRole.ADMIN, status.HTTP_200_OK), + ], +) +async def test_access_rights_on_search_users_only_product_owners_can_access( + client: TestClient, + logged_user: UserInfoDict, + expected: HTTPStatus, + pre_registration_details_db_cleanup: None, +): + assert client.app + + url = client.app.router["search_user_accounts"].url_for() + assert url.path == "/v0/admin/user-accounts:search" + + resp = await client.get(url.path, params={"email": "do-not-exists@foo.com"}) + await assert_status(resp, expected) + + +async def test_access_rights_on_search_users_support_user_can_access_when_above_guest( + support_user: UserInfoDict, + # keep support_user first since it has to be created before the app starts + client: TestClient, + pre_registration_details_db_cleanup: None, +): + """Test that support users with role > GUEST can access the search endpoint.""" + assert client.app + + from pytest_simcore.helpers.webserver_login import switch_client_session_to # noqa: PLC0415 + + # Switch client session to the support user + async with switch_client_session_to(client, support_user): + url = client.app.router["search_user_accounts"].url_for() + assert url.path == "/v0/admin/user-accounts:search" + + resp = await client.get(url.path, params={"email": "do-not-exists@foo.com"}) + await assert_status(resp, status.HTTP_200_OK) + + +@pytest.mark.acceptance_test("pre-registration in https://github.com/ITISFoundation/osparc-simcore/issues/5138") +async def test_search_and_pre_registration( + client: TestClient, + logged_user: UserInfoDict, + account_request_form: dict[str, Any], + pre_registration_details_db_cleanup: None, +): + assert client.app + + # NOTE: listing of user accounts drops nullable fields to avoid lengthy responses (even if they have no defaults) + # therefore they are reconstructed here from http response payloads + nullable_fields = {name: None for name, field in UserAccountGet.model_fields.items() if is_nullable(field)} + + # ONLY in `users` and NOT `users_pre_registration_details` + search_url = client.app.router["search_user_accounts"].url_for() + assert search_url.path == "/v0/admin/user-accounts:search" + resp = await client.get(f"{search_url}", params={"email": logged_user["email"]}) + assert resp.status == status.HTTP_200_OK + + found, _ = await assert_status(resp, status.HTTP_200_OK) + assert len(found) == 1 + + got = UserAccountGet.model_validate({**nullable_fields, **found[0]}) + expected = { + "first_name": logged_user.get("first_name"), + "last_name": logged_user.get("last_name"), + "email": logged_user["email"], + "institution": None, + "phone": logged_user.get("phone"), + "address": None, + "city": None, + "state": None, + "postal_code": None, + "country": None, + "extras": {}, + "registered": True, + "status": UserStatus.ACTIVE, + "user_id": logged_user["id"], + "user_name": logged_user["name"], + "user_primary_group_id": logged_user.get("primary_gid"), + } + assert got.model_dump(include=set(expected)) == expected + + # NOT in `users` and ONLY `users_pre_registration_details` + + # create pre-registration + pre_register_url = client.app.router["pre_register_user_account"].url_for() + assert pre_register_url.path == "/v0/admin/user-accounts:pre-register" + resp = await client.post(f"{pre_register_url}", json=account_request_form) + assert resp.status == status.HTTP_200_OK + + resp = await client.get( + f"{search_url}", + params={"email": account_request_form["email"]}, + ) + found, _ = await assert_status(resp, status.HTTP_200_OK) + assert len(found) == 1 + + got = UserAccountGet.model_validate({**nullable_fields, **found[0]}) + assert got.model_dump(include={"registered", "status"}) == { + "registered": False, + "status": None, + } + + # Emulating registration of pre-register user + new_user = await _auth_service.create_user( + client.app, + email=account_request_form["email"], + password=DEFAULT_TEST_PASSWORD, + status_upon_creation=UserStatus.ACTIVE, + expires_at=None, + ) + + resp = await client.get( + f"{search_url}", + params={"email": account_request_form["email"]}, + ) + found, _ = await assert_status(resp, status.HTTP_200_OK) + assert len(found) == 1 + + got = UserAccountGet.model_validate({**nullable_fields, **found[0]}) + assert got.model_dump(include={"registered", "status"}) == { + "registered": True, + "status": new_user["status"], + } diff --git a/services/web/server/tests/unit/with_dbs/03/users/test_users_accounts_repository.py b/services/web/server/tests/unit/with_dbs/03/users/test_users_accounts_repository.py index f38432e09968..0050e2ba5e41 100644 --- a/services/web/server/tests/unit/with_dbs/03/users/test_users_accounts_repository.py +++ b/services/web/server/tests/unit/with_dbs/03/users/test_users_accounts_repository.py @@ -814,6 +814,71 @@ async def test_list_merged_users_multiple_statuses( assert mixed_user_data.approved_email in mixed_status_emails +@pytest.mark.parametrize( + "filter_statuses,filter_registered,expected_present,expected_absent", + [ + ( + [AccountRequestStatus.PENDING], + True, + ["product_owner_email"], + ["pre_reg_email", "approved_email"], + ), + ( + [AccountRequestStatus.PENDING], + False, + ["pre_reg_email"], + ["product_owner_email", "approved_email"], + ), + ( + [AccountRequestStatus.APPROVED], + False, + ["approved_email"], + ["pre_reg_email", "product_owner_email"], + ), + ( + [AccountRequestStatus.APPROVED], + True, + [], + ["pre_reg_email", "product_owner_email", "approved_email"], + ), + ], +) +async def test_list_merged_users_with_registered_filter( + app: web.Application, + product_name: ProductName, + mixed_user_data: MixedUserTestData, + filter_statuses: list[AccountRequestStatus], + filter_registered: bool, + expected_present: list[str], + expected_absent: list[str], +): + """Test account request status and registered filters in combination.""" + asyncpg_engine = get_asyncpg_engine(app) + + users_list, total_count = await _accounts_repository.list_merged_pre_and_registered_users( + asyncpg_engine, + product_name=product_name, + filter_any_account_request_status=filter_statuses, + filter_registered=filter_registered, + filter_include_deleted=False, + pagination_limit=100, + pagination_offset=0, + ) + + # Assert count and page payload are aligned under filter constraints + assert total_count == len(users_list) + + found_emails = {user["email"] for user in users_list} + + for expected_attr in expected_present: + assert getattr(mixed_user_data, expected_attr) in found_emails + + for expected_attr in expected_absent: + assert getattr(mixed_user_data, expected_attr) not in found_emails + + assert all((user["user_id"] is not None) is filter_registered for user in users_list) + + async def test_list_merged_users_pagination( app: web.Application, product_name: ProductName,