diff --git a/src/handlers/admin_catalog_operations.py b/src/handlers/admin_catalog_operations.py new file mode 100644 index 00000000..80cde9d9 --- /dev/null +++ b/src/handlers/admin_catalog_operations.py @@ -0,0 +1,197 @@ +""" +Admin managed-catalog handler (extracted from admin_operations, #773). + +Provides: +- createManagedCatalog: Create an ADMIN_MANAGED global catalog + +The handler stays registered on the shared admin-operations Lambda: +``admin_operations`` imports it for its operation dispatcher map, so the +Lambda entry point (``handlers.admin_operations.lambda_handler``) and the +AppSync handler mapping are unchanged. +""" + +import uuid +from datetime import datetime, timezone +from decimal import Decimal, InvalidOperation +from typing import TYPE_CHECKING, Any, Dict + +from botocore.exceptions import ClientError + +# Handle both Lambda (absolute) and unit test (relative) imports +try: # pragma: no cover + from utils.appsync_types import get_caller_id + from utils.auth import require_admin_mfa + from utils.dynamodb import tables + from utils.errors import AppError, ErrorCode + from utils.logging import get_logger +except ModuleNotFoundError: # pragma: no cover + from ..utils.appsync_types import get_caller_id + from ..utils.auth import require_admin_mfa + from ..utils.dynamodb import tables + from ..utils.errors import AppError, ErrorCode + from ..utils.logging import get_logger + +# The decorator stays typed for mypy via the relative import below; at runtime +# the absolute import resolves in the Lambda zip (package `utils`) and the +# relative fallback resolves in unit tests (package `src.handlers`). +if TYPE_CHECKING: # pragma: no cover + from ..utils.handlers import lambda_handler as with_error_handling +else: # pragma: no cover + try: + from utils.handlers import lambda_handler as with_error_handling + except ModuleNotFoundError: + from ..utils.handlers import lambda_handler as with_error_handling + + +def _parse_product_price(price: Any) -> Decimal: + """Convert a product price (number or numeric string) to a validated Decimal. + + A missing price, a non-numeric/unparsable value, a non-finite value (Infinity/inf/NaN), or a negative price + raises INVALID_INPUT. + """ + if price is None: + raise AppError(ErrorCode.INVALID_INPUT, "Valid product price is required") + + try: + price_decimal = Decimal(str(price)) + except InvalidOperation, ValueError, TypeError: + raise AppError(ErrorCode.INVALID_INPUT, "Product price must be a valid number") + + if not price_decimal.is_finite(): + raise AppError(ErrorCode.INVALID_INPUT, "Product price must be a valid number") + + if price_decimal < 0: + raise AppError(ErrorCode.INVALID_INPUT, "Valid product price is required") + return price_decimal + + +def _validate_and_process_product(product: Dict[str, Any]) -> Dict[str, Any]: + """Validate and process a catalog product, returning processed product dict. + + Price may arrive as a number or a numeric string (e.g. from AppSync JSON + deserialization); it is converted to Decimal before validation and storage. + Non-numeric or unparsable values raise INVALID_INPUT. + """ + product_name = product.get("productName", "").strip() + + if not product_name: + raise AppError(ErrorCode.INVALID_INPUT, "Product name is required") + + processed_product: Dict[str, Any] = { + "productId": f"PRODUCT#{uuid.uuid4()}", + "productName": product_name, + "price": _parse_product_price(product.get("price")), + "sortOrder": product.get("sortOrder", 0), + } + + description = product.get("description", "").strip() + if description: + processed_product["description"] = description + + return processed_product + + +def _validate_catalog_input(catalog_input: Dict[str, Any]) -> tuple[str, bool, list[Dict[str, Any]]]: + """Validate catalog input and extract name, isPublic, products.""" + catalog_name = catalog_input.get("catalogName", "").strip() + is_public = catalog_input.get("isPublic", True) + products = catalog_input.get("products", []) + + if not catalog_name: + raise AppError(ErrorCode.INVALID_INPUT, "Catalog name is required") + if not products: + raise AppError(ErrorCode.INVALID_INPUT, "Products array cannot be empty") + + return catalog_name, is_public, products + + +def _build_catalog_item( + catalog_id: str, + catalog_name: str, + is_public: bool, + caller_id: str, + processed_products: list[Dict[str, Any]], + now: str, +) -> Dict[str, Any]: + """Build the catalog item dictionary for DynamoDB.""" + return { + "catalogId": catalog_id, + "catalogName": catalog_name, + "catalogType": "ADMIN_MANAGED", # Key difference from regular createCatalog + "ownerAccountId": f"ACCOUNT#{caller_id}", # Track who created it + "isPublic": is_public, + "isPublicStr": "true" if is_public else "false", # For GSI queries + "products": processed_products, + "createdAt": now, + "updatedAt": now, + } + + +def _persist_catalog(catalog_item: Dict[str, Any], logger: Any) -> None: + """Persist catalog to DynamoDB.""" + try: + tables.catalogs.put_item(Item=catalog_item) + except ClientError as e: + logger.error("Failed to create catalog", error=str(e)) + raise AppError(ErrorCode.INTERNAL_ERROR, "Failed to create catalog") + + +def _require_admin_and_get_actor_sub(event: Dict[str, Any]) -> str: + """Require admin + MFA and return the acting administrator's own ``sub`` (#507). + + The actor counterpart to `_require_admin_and_get_target_account_id`: this + one returns the caller, whose sub is what the audit line must record. + """ + require_admin_mfa(event) + + caller_id = get_caller_id(event) + if not caller_id: + raise AppError(ErrorCode.UNAUTHORIZED, "Authentication required") + + return str(caller_id) + + +@with_error_handling(error_message="Failed to create catalog") +def create_managed_catalog(event: Dict[str, Any], context: Any) -> Dict[str, Any]: + """ + Create an ADMIN_MANAGED catalog (admin only). + + AppSync Lambda resolver for createManagedCatalog mutation. + Creates a global catalog that is available to all users. + + Args: + event: AppSync event with identity and arguments + context: Lambda context + + Returns: + Created Catalog object + + Raises: + AppError: If not admin, validation fails, or creation error + """ + logger = get_logger(__name__) + + caller_id = _require_admin_and_get_actor_sub(event) + + arguments = event.get("arguments", {}) + catalog_input = arguments.get("input", {}) + + catalog_name, is_public, products = _validate_catalog_input(catalog_input) + + catalog_id = f"CATALOG#{uuid.uuid4()}" + now = datetime.now(timezone.utc).isoformat() + + processed_products = [_validate_and_process_product(product) for product in products] + + catalog_item = _build_catalog_item(catalog_id, catalog_name, is_public, caller_id, processed_products, now) + + _persist_catalog(catalog_item, logger) + + logger.info( + "Created managed catalog", + catalog_id=catalog_id, + catalog_name=catalog_name, + actor_sub=caller_id, + ) + + return catalog_item diff --git a/src/handlers/admin_credential_reset.py b/src/handlers/admin_credential_reset.py new file mode 100644 index 00000000..b909b748 --- /dev/null +++ b/src/handlers/admin_credential_reset.py @@ -0,0 +1,126 @@ +""" +Admin password-reset handler (extracted from admin_operations, #773). + +Provides: +- adminResetUserPassword: Send password reset email to user + +The handler stays registered on the shared admin-operations Lambda: +``admin_operations`` imports it for its operation dispatcher map, so the +Lambda entry point (``handlers.admin_operations.lambda_handler``) and the +AppSync handler mapping are unchanged. +""" + +from typing import TYPE_CHECKING, Any, Dict + +from botocore.exceptions import ClientError + +# Handle both Lambda (absolute) and unit test (relative) imports +try: # pragma: no cover + from utils.auth import require_admin_mfa + from utils.boto import get_cognito_client + from utils.cognito_filters import cognito_user_filter + from utils.dynamodb import get_required_env + from utils.errors import AppError, ErrorCode + from utils.logging import get_logger, mask_email +except ModuleNotFoundError: # pragma: no cover + from ..utils.auth import require_admin_mfa + from ..utils.boto import get_cognito_client + from ..utils.cognito_filters import cognito_user_filter + from ..utils.dynamodb import get_required_env + from ..utils.errors import AppError, ErrorCode + from ..utils.logging import get_logger, mask_email + +# The decorator stays typed for mypy via the relative import below; at runtime +# the absolute import resolves in the Lambda zip (package `utils`) and the +# relative fallback resolves in unit tests (package `src.handlers`). +if TYPE_CHECKING: # pragma: no cover + from ..utils.handlers import lambda_handler as with_error_handling +else: # pragma: no cover + try: + from utils.handlers import lambda_handler as with_error_handling + except ModuleNotFoundError: + from ..utils.handlers import lambda_handler as with_error_handling + + +def _find_user_by_email(cognito: Any, user_pool_id: str, email: str, logger: Any) -> str: + """Find username by email. Returns username.""" + try: + response = cognito.list_users( + UserPoolId=user_pool_id, + # Built in one step so the value that was checked is the value sent (#124, #560). + Filter=cognito_user_filter("email", email), + Limit=1, + ) + except ClientError as e: + logger.error("Cognito list_users failed", error=str(e)) + raise AppError(ErrorCode.INTERNAL_ERROR, "Failed to search for user") + + users = response.get("Users", []) + if not users: + raise AppError(ErrorCode.NOT_FOUND, f"User with email '{email}' not found") + + return str(users[0]["Username"]) + + +def _initiate_password_reset(cognito: Any, user_pool_id: str, username: str, email: str, logger: Any) -> None: + """Initiate Cognito password reset for user.""" + try: + cognito.admin_reset_user_password( + UserPoolId=user_pool_id, + Username=username, + ) + except ClientError as e: + error_code = e.response.get("Error", {}).get("Code", "") + if error_code == "UserNotFoundException": + raise AppError(ErrorCode.NOT_FOUND, f"User with email '{email}' not found") + if error_code == "InvalidParameterException": + raise AppError(ErrorCode.INVALID_INPUT, "Cannot reset password for this user type") + logger.error("Cognito admin_reset_user_password failed", error=str(e)) + raise AppError(ErrorCode.INTERNAL_ERROR, "Failed to initiate password reset") + + +@with_error_handling(error_message="Failed to reset password") +def admin_reset_user_password(event: Dict[str, Any], context: Any) -> bool: + """ + Send password reset email to user (admin only). + + AppSync Lambda resolver for adminResetUserPassword mutation. + + Args: + event: AppSync event with identity and arguments + context: Lambda context + + Returns: + True if password reset was initiated successfully + + Raises: + AppError: If not admin, user not found, or Cognito error + """ + from .admin_operations import _actor_sub + + logger = get_logger(__name__) + + # Verify caller is admin and extract email + require_admin_mfa(event) + + arguments = event.get("arguments", {}) + email = arguments.get("email", "").strip().lower() + + if not email: + raise AppError(ErrorCode.INVALID_INPUT, "Email is required") + + user_pool_id = get_required_env("USER_POOL_ID") + cognito = get_cognito_client() + + # Find user and initiate reset + username = _find_user_by_email(cognito, user_pool_id, email, logger) + _initiate_password_reset(cognito, user_pool_id, username, email, logger) + + actor_sub = _actor_sub(event) + logger.info( + "Password reset initiated", + email=mask_email(email), + username=mask_email(username), + actor_sub=actor_sub, + ) + return True diff --git a/src/handlers/admin_operations.py b/src/handlers/admin_operations.py index 1f17df2d..f426d20b 100644 --- a/src/handlers/admin_operations.py +++ b/src/handlers/admin_operations.py @@ -3,11 +3,12 @@ Provides: - adminResetUserPassword: Send password reset email to user -- createManagedCatalog: Create an ADMIN_MANAGED global catalog -The adminListUsers / adminSearchUser handlers live in ``admin_user_operations`` -and the adminPurgeUserAccount handler lives in ``admin_account_purge`` (#773); -both are imported below so the dispatcher map stays the single registry. +The adminListUsers / adminSearchUser handlers live in ``admin_user_operations``, +the adminPurgeUserAccount handler lives in ``admin_account_purge``, the +createManagedCatalog handler lives in ``admin_catalog_operations``, and the +per-entity adminDeleteUser* handlers live in ``admin_user_deletion`` (#773); +all four are imported below so the dispatcher map stays the single registry. User deletion is client-side by decision (#521). The client issues the per-entity ``adminDeleteUser*`` mutations it can reach, reads the profile IDs @@ -28,51 +29,21 @@ principals. """ -import uuid -from datetime import datetime, timezone -from decimal import Decimal, InvalidOperation -from typing import TYPE_CHECKING, Any, Dict, NoReturn, Optional, cast +from typing import TYPE_CHECKING, Any, Dict, NoReturn from botocore.exceptions import ClientError -# Sibling handler modules use a same-package relative import, which resolves both -# in the Lambda zip (package `handlers`) and in unit tests (package `src.handlers`). -from .deletion_cascade import ( - delete_user_campaigns, - delete_user_orders, - delete_user_profiles, - delete_user_shares, - get_user_profiles, - normalize_account_id, -) - # Handle both Lambda (absolute) and unit test (relative) imports try: # pragma: no cover from utils.appsync_types import get_caller_id from utils.auth import require_admin_mfa - from utils.boto import get_cognito_client - from utils.cognito_filters import cognito_user_filter - from utils.dynamodb import ( - TRANSIENT_ERROR_CODES, - batch_get_chunked, - get_required_env, - tables, - ) + from utils.dynamodb import TRANSIENT_ERROR_CODES from utils.errors import AppError, ErrorCode - from utils.logging import get_logger, mask_email except ModuleNotFoundError: # pragma: no cover from ..utils.appsync_types import get_caller_id from ..utils.auth import require_admin_mfa - from ..utils.boto import get_cognito_client - from ..utils.cognito_filters import cognito_user_filter - from ..utils.dynamodb import ( - TRANSIENT_ERROR_CODES, - batch_get_chunked, - get_required_env, - tables, - ) + from ..utils.dynamodb import TRANSIENT_ERROR_CODES from ..utils.errors import AppError, ErrorCode - from ..utils.logging import get_logger, mask_email # The decorator stays typed for mypy via the relative import below; at runtime # the absolute import resolves in the Lambda zip (package `utils`) and the @@ -91,21 +62,50 @@ # `admin_account_purge` only imports from this module inside functions. from .admin_account_purge import admin_purge_user_account +# The managed-catalog handler was extracted to its own module (#773) but stays +# registered on this Lambda. The import is safe at module load because +# `admin_catalog_operations` does not import from this module at all. +from .admin_catalog_operations import create_managed_catalog + +# The password-reset handler was extracted to its own module (#773) but stays +# registered on this Lambda. The import is safe at module load because +# `admin_credential_reset` only imports from this module inside functions. +from .admin_credential_reset import admin_reset_user_password + +# The profile-share and campaign shared-code handlers were extracted to their +# own module (#773) but stay registered on this Lambda. The import is safe at +# module load because `admin_share_campaign_ops` only imports from this module +# inside functions. +from .admin_share_campaign_ops import ( + admin_delete_share, + admin_get_profile_shares, + admin_update_campaign_shared_code, +) + +# The per-entity user deletion handlers were extracted to their own module (#773) +# but stay registered on this Lambda. The import is safe at module load because +# `admin_user_deletion` only imports from this module inside functions. +from .admin_user_deletion import ( + admin_delete_user_campaigns, + admin_delete_user_orders, + admin_delete_user_profiles, + admin_delete_user_shares, +) + # The user listing & search handlers were extracted to their own module (#773) but # stay registered on this Lambda. The import is safe at module load because # `admin_user_operations` only imports from this module inside functions. from .admin_user_operations import admin_list_users, admin_search_user -# Handle both Lambda (absolute) and unit test (relative) imports. mypy sees the -# relative path it can resolve; the runtime fallback tries absolute first for Lambda. -if TYPE_CHECKING: # pragma: no cover - from ..utils.pagination import query_all_items -else: # pragma: no cover - try: - from utils.pagination import query_all_items - except ModuleNotFoundError: - from ..utils.pagination import query_all_items - +# The account-scoped admin user read handlers were extracted to their own module +# (#773) but stay registered on this Lambda. The import is safe at module load +# because `admin_user_reads` only imports from this module inside functions. +from .admin_user_reads import ( + admin_get_user_campaigns, + admin_get_user_catalogs, + admin_get_user_profiles, + admin_get_user_shared_campaigns, +) # DynamoDB/Cognito throttling codes: the lookup is retryable, so surface a # RESOURCE_BUSY error instead of silently incomplete admin data (#291, #456). @@ -224,725 +224,6 @@ def _require_admin_and_get_target_account_id(event: Dict[str, Any]) -> tuple[str return account_id, actor_sub -def _find_user_by_email(cognito: Any, user_pool_id: str, email: str, logger: Any) -> str: - """Find username by email. Returns username.""" - try: - response = cognito.list_users( - UserPoolId=user_pool_id, - # Built in one step so the value that was checked is the value sent (#124, #560). - Filter=cognito_user_filter("email", email), - Limit=1, - ) - except ClientError as e: - logger.error("Cognito list_users failed", error=str(e)) - raise AppError(ErrorCode.INTERNAL_ERROR, "Failed to search for user") - - users = response.get("Users", []) - if not users: - raise AppError(ErrorCode.NOT_FOUND, f"User with email '{email}' not found") - - return str(users[0]["Username"]) - - -def _initiate_password_reset(cognito: Any, user_pool_id: str, username: str, email: str, logger: Any) -> None: - """Initiate Cognito password reset for user.""" - try: - cognito.admin_reset_user_password( - UserPoolId=user_pool_id, - Username=username, - ) - except ClientError as e: - error_code = e.response.get("Error", {}).get("Code", "") - if error_code == "UserNotFoundException": - raise AppError(ErrorCode.NOT_FOUND, f"User with email '{email}' not found") - if error_code == "InvalidParameterException": - raise AppError(ErrorCode.INVALID_INPUT, "Cannot reset password for this user type") - logger.error("Cognito admin_reset_user_password failed", error=str(e)) - raise AppError(ErrorCode.INTERNAL_ERROR, "Failed to initiate password reset") - - -@with_error_handling(error_message="Failed to reset password") -def admin_reset_user_password(event: Dict[str, Any], context: Any) -> bool: - """ - Send password reset email to user (admin only). - - AppSync Lambda resolver for adminResetUserPassword mutation. - - Args: - event: AppSync event with identity and arguments - context: Lambda context - - Returns: - True if password reset was initiated successfully - - Raises: - AppError: If not admin, user not found, or Cognito error - """ - logger = get_logger(__name__) - - # Verify caller is admin and extract email - require_admin_mfa(event) - - arguments = event.get("arguments", {}) - email = arguments.get("email", "").strip().lower() - - if not email: - raise AppError(ErrorCode.INVALID_INPUT, "Email is required") - - user_pool_id = get_required_env("USER_POOL_ID") - cognito = get_cognito_client() - - # Find user and initiate reset - username = _find_user_by_email(cognito, user_pool_id, email, logger) - _initiate_password_reset(cognito, user_pool_id, username, email, logger) - - actor_sub = _actor_sub(event) - logger.info( - "Password reset initiated", - email=mask_email(email), - username=mask_email(username), - actor_sub=actor_sub, - ) - return True - - -def _parse_product_price(price: Any) -> Decimal: - """Convert a product price (number or numeric string) to a validated Decimal. - - A missing price, a non-numeric/unparsable value, a non-finite value - (Infinity/inf/NaN), or a negative price raises INVALID_INPUT. - """ - if price is None: - raise AppError(ErrorCode.INVALID_INPUT, "Valid product price is required") - - try: - price_decimal = Decimal(str(price)) - except InvalidOperation, ValueError, TypeError: - raise AppError(ErrorCode.INVALID_INPUT, "Product price must be a valid number") - - if not price_decimal.is_finite(): - raise AppError(ErrorCode.INVALID_INPUT, "Product price must be a valid number") - - if price_decimal < 0: - raise AppError(ErrorCode.INVALID_INPUT, "Valid product price is required") - return price_decimal - - -def _validate_and_process_product(product: Dict[str, Any]) -> Dict[str, Any]: - """Validate and process a catalog product, returning processed product dict. - - Price may arrive as a number or a numeric string (e.g. from AppSync JSON - deserialization); it is converted to Decimal before validation and storage. - Non-numeric or unparsable values raise INVALID_INPUT. - """ - product_name = product.get("productName", "").strip() - - if not product_name: - raise AppError(ErrorCode.INVALID_INPUT, "Product name is required") - - processed_product: Dict[str, Any] = { - "productId": f"PRODUCT#{uuid.uuid4()}", - "productName": product_name, - "price": _parse_product_price(product.get("price")), - "sortOrder": product.get("sortOrder", 0), - } - - description = product.get("description", "").strip() - if description: - processed_product["description"] = description - - return processed_product - - -def _validate_catalog_input(catalog_input: Dict[str, Any]) -> tuple[str, bool, list[Dict[str, Any]]]: - """Validate catalog input and extract name, isPublic, products.""" - catalog_name = catalog_input.get("catalogName", "").strip() - is_public = catalog_input.get("isPublic", True) - products = catalog_input.get("products", []) - - if not catalog_name: - raise AppError(ErrorCode.INVALID_INPUT, "Catalog name is required") - if not products: - raise AppError(ErrorCode.INVALID_INPUT, "Products array cannot be empty") - - return catalog_name, is_public, products - - -def _build_catalog_item( - catalog_id: str, - catalog_name: str, - is_public: bool, - caller_id: str, - processed_products: list[Dict[str, Any]], - now: str, -) -> Dict[str, Any]: - """Build the catalog item dictionary for DynamoDB.""" - return { - "catalogId": catalog_id, - "catalogName": catalog_name, - "catalogType": "ADMIN_MANAGED", # Key difference from regular createCatalog - "ownerAccountId": f"ACCOUNT#{caller_id}", # Track who created it - "isPublic": is_public, - "isPublicStr": "true" if is_public else "false", # For GSI queries - "products": processed_products, - "createdAt": now, - "updatedAt": now, - } - - -def _persist_catalog(catalog_item: Dict[str, Any], logger: Any) -> None: - """Persist catalog to DynamoDB.""" - try: - tables.catalogs.put_item(Item=catalog_item) - except ClientError as e: - logger.error("Failed to create catalog", error=str(e)) - raise AppError(ErrorCode.INTERNAL_ERROR, "Failed to create catalog") - - -def _require_admin_and_get_actor_sub(event: Dict[str, Any]) -> str: - """Require admin + MFA and return the acting administrator's own ``sub`` (#507). - - The actor counterpart to `_require_admin_and_get_target_account_id`: this - one returns the caller, whose sub is what the audit line must record. - """ - require_admin_mfa(event) - - caller_id = get_caller_id(event) - if not caller_id: - raise AppError(ErrorCode.UNAUTHORIZED, "Authentication required") - - return str(caller_id) - - -@with_error_handling(error_message="Failed to create catalog") -def create_managed_catalog(event: Dict[str, Any], context: Any) -> Dict[str, Any]: - """ - Create an ADMIN_MANAGED catalog (admin only). - - AppSync Lambda resolver for createManagedCatalog mutation. - Creates a global catalog that is available to all users. - - Args: - event: AppSync event with identity and arguments - context: Lambda context - - Returns: - Created Catalog object - - Raises: - AppError: If not admin, validation fails, or creation error - """ - logger = get_logger(__name__) - - caller_id = _require_admin_and_get_actor_sub(event) - - arguments = event.get("arguments", {}) - catalog_input = arguments.get("input", {}) - - catalog_name, is_public, products = _validate_catalog_input(catalog_input) - - catalog_id = f"CATALOG#{uuid.uuid4()}" - now = datetime.now(timezone.utc).isoformat() - - processed_products = [_validate_and_process_product(product) for product in products] - - catalog_item = _build_catalog_item(catalog_id, catalog_name, is_public, caller_id, processed_products, now) - - _persist_catalog(catalog_item, logger) - - logger.info( - "Created managed catalog", - catalog_id=catalog_id, - catalog_name=catalog_name, - actor_sub=caller_id, - ) - - return catalog_item - - -def _user_profiles_for(account_id: str) -> list[Dict[str, Any]]: - """Sweep the profiles table once for a per-domain admin deletion.""" - return get_user_profiles(normalize_account_id(account_id)) - - -@with_error_handling(error_message="Failed to delete user orders") -def admin_delete_user_orders(event: Dict[str, Any], context: Any) -> int: - """ - Delete all orders for all campaigns of all profiles owned by a user (admin only). - - Verifies with strongly consistent reads that each deleted order is gone - from the orders table before returning success. - - Returns the count of deleted orders. - """ - logger = get_logger(__name__) - - account_id, actor_sub = _require_admin_and_get_target_account_id(event) - deleted_count = delete_user_orders(_user_profiles_for(account_id), logger) - logger.info("Admin deleted user orders", account_id=account_id, count=deleted_count, actor_sub=actor_sub) - return deleted_count - - -@with_error_handling(error_message="Failed to delete user campaigns") -def admin_delete_user_campaigns(event: Dict[str, Any], context: Any) -> int: - """ - Delete all campaigns for all profiles owned by a user (admin only). - - Verifies with strongly consistent reads that each deleted campaign is gone - from the campaigns table before returning success. - - Returns the count of deleted campaigns. - """ - logger = get_logger(__name__) - - account_id, actor_sub = _require_admin_and_get_target_account_id(event) - deleted_count = delete_user_campaigns(_user_profiles_for(account_id), logger) - logger.info("Admin deleted user campaigns", account_id=account_id, count=deleted_count, actor_sub=actor_sub) - return deleted_count - - -@with_error_handling(error_message="Failed to delete user shares") -def admin_delete_user_shares(event: Dict[str, Any], context: Any) -> int: - """ - Delete all shares for all profiles owned by a user (admin only). - - Returns the count of deleted shares. - """ - logger = get_logger(__name__) - - account_id, actor_sub = _require_admin_and_get_target_account_id(event) - deleted_count = delete_user_shares(_user_profiles_for(account_id), logger) - logger.info("Admin deleted user shares", account_id=account_id, count=deleted_count, actor_sub=actor_sub) - return deleted_count - - -@with_error_handling(error_message="Failed to delete user profiles") -def admin_delete_user_profiles(event: Dict[str, Any], context: Any) -> int: - """ - Delete all profiles owned by a user (admin only). - - Returns the count of deleted profiles. - """ - logger = get_logger(__name__) - - account_id, actor_sub = _require_admin_and_get_target_account_id(event) - deleted_count = delete_user_profiles(account_id, _user_profiles_for(account_id), logger) - logger.info("Admin deleted user profiles", account_id=account_id, count=deleted_count, actor_sub=actor_sub) - return deleted_count - - -@with_error_handling(error_message="Failed to get user profiles") -def admin_get_user_profiles(event: Dict[str, Any], context: Any) -> list[Dict[str, Any]]: - """ - Get all profiles owned by a user (admin only). - - Returns the list of profiles with full details. - """ - logger = get_logger(__name__) - - account_id, actor_sub = _require_admin_and_get_target_account_id(event) - db_account_id = normalize_account_id(account_id) - - # Query profiles by ownerAccountId - profiles = query_all_items( - tables.profiles, - { - "KeyConditionExpression": "ownerAccountId = :owner", - "ExpressionAttributeValues": {":owner": db_account_id}, - }, - ) - - logger.info("Retrieved user profiles", account_id=account_id, count=len(profiles), actor_sub=actor_sub) - return profiles - - -@with_error_handling(error_message="Failed to get user catalogs") -def admin_get_user_catalogs(event: Dict[str, Any], context: Any) -> list[Dict[str, Any]]: - """ - Get all catalogs owned by a user (admin only). - - Returns the list of catalogs with full details. - """ - logger = get_logger(__name__) - - account_id, actor_sub = _require_admin_and_get_target_account_id(event) - db_account_id = normalize_account_id(account_id) - - # Query catalogs by ownerAccountId using GSI - catalogs = query_all_items( - tables.catalogs, - { - "IndexName": "ownerAccountId-index", - "KeyConditionExpression": "ownerAccountId = :owner", - "FilterExpression": "attribute_not_exists(isDeleted) OR isDeleted = :false", - "ExpressionAttributeValues": { - ":owner": db_account_id, - ":false": False, - }, - }, - ) - - logger.info("Retrieved user catalogs", account_id=account_id, count=len(catalogs), actor_sub=actor_sub) - return catalogs - - -def _extract_unique_catalog_ids(campaigns: list[Dict[str, Any]]) -> list[str]: - """Extract the de-duplicated catalogIds from a campaign array, preserving first-seen order.""" - catalog_ids: list[str] = [] - seen: set[str] = set() - for campaign in campaigns: - catalog_id = campaign.get("catalogId") - if catalog_id and catalog_id not in seen: - seen.add(catalog_id) - catalog_ids.append(catalog_id) - return catalog_ids - - -def _batch_get_catalog_items( - catalog_ids: list[str], catalogs_table_name: str, logger: Any -) -> Dict[str, Dict[str, Any]]: - """Batch-get the catalogs by id; the shared helper owns the 100-key chunking. - - Raises AppError if keys stay unprocessed after the retries (#557). - """ - catalog_map: Dict[str, Dict[str, Any]] = {} - - def _store_catalog(item: Dict[str, Any]) -> None: - catalog_map[item["catalogId"]] = item - - batch_get_chunked( - catalogs_table_name, - [{"catalogId": catalog_id} for catalog_id in catalog_ids], - _store_catalog, - consistent_read=False, - logger=logger, - ) - return catalog_map - - -def _campaign_catalog_value(catalog: Dict[str, Any] | None, treat_deleted_as_null: bool) -> Dict[str, Any] | None: - """Map a fetched catalog to its Campaign/SharedCampaign catalog contract value.""" - if catalog is not None and treat_deleted_as_null and catalog.get("isDeleted") is True: - return None - return catalog - - -def _attach_campaign_catalogs( - campaigns: list[Dict[str, Any]], catalog_map: Dict[str, Dict[str, Any]], treat_deleted_as_null: bool -) -> None: - """Attach each campaign's fetched catalog, mapping missing/soft-deleted per contract.""" - for campaign in campaigns: - catalog_id = campaign.get("catalogId") - if not catalog_id: - continue - campaign["catalog"] = _campaign_catalog_value(catalog_map.get(catalog_id), treat_deleted_as_null) - - -def _batch_get_campaign_catalogs(campaigns: list[Dict[str, Any]], treat_deleted_as_null: bool, logger: Any) -> None: - """Batch-fetch the catalogs referenced by campaigns and attach each as `catalog`. - - Mirrors the #332 list-query pipeline batching (batch_get_catalogs_fn.js / - batch_get_shared_campaign_catalogs_fn.js) inside the admin Lambda, which - can chunk and loop where AppSync pipeline functions cannot. AppSync always - executes the attached Campaign.catalog / SharedCampaign.catalog field - resolver; since check_source_catalog_fn.js short-circuits when the source - already carries `catalog`, attaching it here replaces the per-item GetItem - (N+1) the admin campaign lists previously paid. - - Deleted-catalog contract matches the field resolvers: Campaign.catalog - returns the raw item including soft-deleted catalogs (treat_deleted_as_null - = False); SharedCampaign.catalog maps soft-deleted or missing catalogs to - None (treat_deleted_as_null = True). - """ - catalog_ids = _extract_unique_catalog_ids(campaigns) - if not catalog_ids: - return - - catalogs_table_name = get_required_env("CATALOGS_TABLE_NAME") - # batch_get_chunked already translates every lookup failure (retryable - # RESOURCE_BUSY on a throttle, INTERNAL_ERROR otherwise), so the typed - # AppError propagates unchanged (#557). - catalog_map = _batch_get_catalog_items(catalog_ids, catalogs_table_name, logger) - - _attach_campaign_catalogs(campaigns, catalog_map, treat_deleted_as_null) - - -def _get_campaigns_for_profiles(profiles: list[Dict[str, Any]], logger: Any, actor_sub: str) -> list[Dict[str, Any]]: - """Query campaigns for each profile.""" - all_campaigns = [] - for profile in profiles: - profile_id = profile.get("profileId") - if profile_id: - campaigns = query_all_items( - tables.campaigns, - { - "KeyConditionExpression": "profileId = :pid", - "ExpressionAttributeValues": {":pid": profile_id}, - }, - ) - all_campaigns.extend(campaigns) - logger.info( - "Retrieved campaigns for profile", - profile_id=profile_id, - count=len(campaigns), - actor_sub=actor_sub, - ) - return all_campaigns - - -@with_error_handling(error_message="Failed to get user campaigns") -def admin_get_user_campaigns(event: Dict[str, Any], context: Any) -> list[Dict[str, Any]]: - """ - Get all campaigns for a user's profiles. - - Admin-only operation. Returns all campaigns across all profiles owned by the account. - """ - logger = get_logger(__name__) - - account_id, actor_sub = _require_admin_and_get_target_account_id(event) - db_account_id = normalize_account_id(account_id) - - # First, get all profiles owned by this account - profiles = get_user_profiles(db_account_id) - logger.info("Retrieved user profiles", account_id=account_id, count=len(profiles), actor_sub=actor_sub) - - # Now query campaigns for each profile - all_campaigns = _get_campaigns_for_profiles(profiles, logger, actor_sub) - # Pre-resolve catalogs in one chunked BatchGetItem so the Campaign.catalog - # field resolver short-circuits instead of doing per-item GetItem reads (#332). - _batch_get_campaign_catalogs(all_campaigns, treat_deleted_as_null=False, logger=logger) - logger.info("Retrieved user campaigns", account_id=account_id, count=len(all_campaigns), actor_sub=actor_sub) - return all_campaigns - - -@with_error_handling(error_message="Failed to get user shared campaigns") -def admin_get_user_shared_campaigns(event: Dict[str, Any], context: Any) -> list[Dict[str, Any]]: - """ - Get all shared campaigns created by a user. - - Admin-only operation. - """ - logger = get_logger(__name__) - - account_id, actor_sub = _require_admin_and_get_target_account_id(event) - db_account_id = normalize_account_id(account_id) - - # Query shared campaigns by createdBy using GSI1 - campaigns = query_all_items( - tables.shared_campaigns, - { - "IndexName": "GSI1", - "KeyConditionExpression": "createdBy = :creator", - "ExpressionAttributeValues": { - ":creator": db_account_id, - }, - }, - ) - - # Pre-resolve catalogs in one chunked BatchGetItem so the SharedCampaign.catalog - # field resolver short-circuits instead of doing per-item GetItem reads (#332). - _batch_get_campaign_catalogs(campaigns, treat_deleted_as_null=True, logger=logger) - - logger.info( - "Retrieved user shared campaigns", - account_id=account_id, - count=len(campaigns), - actor_sub=actor_sub, - ) - return campaigns - - -def _convert_permissions_to_lists(shares: list[Dict[str, Any]]) -> None: - """Convert DynamoDB permission sets to lists for JSON serialization.""" - for share in shares: - if "permissions" in share and isinstance(share["permissions"], set): - share["permissions"] = list(share["permissions"]) - - -def _query_profile_shares(db_profile_id: str) -> list[Dict[str, Any]]: - """Query all shares for a profile.""" - return query_all_items( - tables.shares, - { - "KeyConditionExpression": "profileId = :pid", - "ExpressionAttributeValues": {":pid": db_profile_id}, - }, - ) - - -def _require_admin_and_get_target_profile_id(event: Dict[str, Any]) -> tuple[str, str]: - """Require admin + MFA, returning ``(db_profile_id, actor_sub)`` (#507). - - ``db_profile_id`` is the target profile named by the request; ``actor_sub`` - is the calling administrator, returned alongside so the audit line can - attribute the operation. - """ - require_admin_mfa(event) - actor_sub = _actor_sub(event) - - arguments = event.get("arguments", {}) - profile_id = arguments.get("profileId", "").strip() - - if not profile_id: - raise AppError(ErrorCode.INVALID_INPUT, "Profile ID is required") - - # Add PROFILE# prefix if not present - db_profile_id = profile_id if profile_id.startswith("PROFILE#") else f"PROFILE#{profile_id}" - return db_profile_id, actor_sub - - -@with_error_handling(error_message="Failed to get profile shares") -def admin_get_profile_shares(event: Dict[str, Any], context: Any) -> list[Dict[str, Any]]: - """ - Get all shares for a specific profile. - - Admin-only operation. Returns all users who have access to this profile. - """ - logger = get_logger(__name__) - - db_profile_id, actor_sub = _require_admin_and_get_target_profile_id(event) - - # Query shares and convert sets to lists - shares = _query_profile_shares(db_profile_id) - _convert_permissions_to_lists(shares) - - logger.info("Retrieved profile shares", profile_id=db_profile_id, count=len(shares), actor_sub=actor_sub) - return shares - - -def _normalize_profile_and_account_ids(profile_id: str, target_account_id: str) -> tuple[str, str]: - """Normalize profile ID and target account ID with prefixes.""" - db_profile_id = profile_id if profile_id.startswith("PROFILE#") else f"PROFILE#{profile_id}" - db_target_id = target_account_id if target_account_id.startswith("ACCOUNT#") else f"ACCOUNT#{target_account_id}" - return db_profile_id, db_target_id - - -def _require_admin_and_get_share_ids(event: Dict[str, Any]) -> tuple[str, str, str]: - """Require admin + MFA, returning ``(profile_id, target_account_id, actor_sub)`` (#507). - - The first two are the share the request names; the third is the calling - administrator, carried so the revocation can be attributed. - """ - require_admin_mfa(event) - actor_sub = _actor_sub(event) - - arguments = event.get("arguments", {}) - profile_id = arguments.get("profileId", "").strip() - target_account_id = arguments.get("targetAccountId", "").strip() - - if not profile_id or not target_account_id: - raise AppError(ErrorCode.INVALID_INPUT, "Profile ID and target account ID are required") - - return profile_id, target_account_id, actor_sub - - -@with_error_handling(error_message="Failed to delete share") -def admin_delete_share(event: Dict[str, Any], context: Any) -> bool: - """ - Delete a specific share (revoke access). - - Admin-only operation. Allows admin to remove someone's access to a profile. - """ - logger = get_logger(__name__) - - profile_id, target_account_id, actor_sub = _require_admin_and_get_share_ids(event) - db_profile_id, db_target_id = _normalize_profile_and_account_ids(profile_id, target_account_id) - - tables.shares.delete_item(Key={"profileId": db_profile_id, "targetAccountId": db_target_id}) - - logger.info( - "Deleted share", - profile_id=profile_id, - target_account_id=target_account_id, - actor_sub=actor_sub, - ) - return True - - -def _get_campaign_profile_id(db_campaign_id: str, campaign_id: str) -> str: - """Get profile ID for a campaign via GSI lookup.""" - response = tables.campaigns.query( - IndexName="campaignId-index", - KeyConditionExpression="campaignId = :cid", - ExpressionAttributeValues={":cid": db_campaign_id}, - ) - - items = response.get("Items", []) - if not items: - raise AppError(ErrorCode.NOT_FOUND, f"Campaign not found: {campaign_id}") - - return str(items[0]["profileId"]) - - -def _update_campaign_shared_code( - profile_id: str, db_campaign_id: str, shared_campaign_code: Optional[str] -) -> Dict[str, Any]: - """Update campaign's sharedCampaignCode field and return the stored item.""" - updated_at = datetime.now(timezone.utc).isoformat() - - if shared_campaign_code is None: - update_expr = "REMOVE sharedCampaignCode SET updatedAt = :updated" - expr_vals = {":updated": updated_at} - else: - update_expr = "SET sharedCampaignCode = :code, updatedAt = :updated" - expr_vals = {":code": shared_campaign_code, ":updated": updated_at} - - response = tables.campaigns.update_item( - Key={"profileId": profile_id, "campaignId": db_campaign_id}, - UpdateExpression=update_expr, - ExpressionAttributeValues=expr_vals, - ReturnValues="ALL_NEW", - ) - - return cast(Dict[str, Any], response.get("Attributes", {})) - - -def _require_admin_and_get_target_campaign_id(event: Dict[str, Any]) -> tuple[str, Optional[str], str]: - """Require admin + MFA, returning ``(campaign_id, shared_campaign_code, actor_sub)`` (#507). - - ``campaign_id`` is the target campaign named by the request; ``actor_sub`` - is the calling administrator, carried so the update can be attributed. - """ - require_admin_mfa(event) - actor_sub = _actor_sub(event) - - arguments = event.get("arguments", {}) - campaign_id = arguments.get("campaignId", "").strip() - shared_campaign_code = arguments.get("sharedCampaignCode") - - if not campaign_id: - raise AppError(ErrorCode.INVALID_INPUT, "Campaign ID is required") - - return campaign_id, shared_campaign_code, actor_sub - - -@with_error_handling(error_message="Failed to update campaign shared code") -def admin_update_campaign_shared_code(event: Dict[str, Any], context: Any) -> Dict[str, Any]: - """ - Update a campaign's sharedCampaignCode field and return the stored campaign item. - - Admin-only operation. Allows associating a campaign with a shared campaign. - """ - logger = get_logger(__name__) - - campaign_id, shared_campaign_code, actor_sub = _require_admin_and_get_target_campaign_id(event) - - # Add CAMPAIGN# prefix if not present - db_campaign_id = campaign_id if campaign_id.startswith("CAMPAIGN#") else f"CAMPAIGN#{campaign_id}" - - # Get campaign profile, update shared code, and return the stored item - profile_id = _get_campaign_profile_id(db_campaign_id, campaign_id) - updated_campaign = _update_campaign_shared_code(profile_id, db_campaign_id, shared_campaign_code) - - logger.info( - "Updated campaign shared code", - campaign_id=campaign_id, - shared_campaign_code=shared_campaign_code, - actor_sub=actor_sub, - ) - return updated_campaign - - # Operation dispatcher map _OPERATION_HANDLERS = { "adminListUsers": admin_list_users, diff --git a/src/handlers/admin_share_campaign_ops.py b/src/handlers/admin_share_campaign_ops.py new file mode 100644 index 00000000..1020369b --- /dev/null +++ b/src/handlers/admin_share_campaign_ops.py @@ -0,0 +1,247 @@ +""" +Admin profile-share & campaign shared-code handlers (extracted from admin_operations, #773). + +Provides: +- adminGetProfileShares: Get all shares for a profile +- adminDeleteShare: Delete a specific share (revoke access) +- adminUpdateCampaignSharedCode: Update a campaign's sharedCampaignCode field + +The handlers stay registered on the shared admin-operations Lambda: +``admin_operations`` imports them for its operation dispatcher map, so the +Lambda entry point (``handlers.admin_operations.lambda_handler``) and the +AppSync handler mapping are unchanged. +""" + +from datetime import datetime, timezone +from typing import TYPE_CHECKING, Any, Dict, Optional, cast + +# Handle both Lambda (absolute) and unit test (relative) imports +try: # pragma: no cover + from utils.auth import require_admin_mfa + from utils.dynamodb import tables + from utils.errors import AppError, ErrorCode + from utils.logging import get_logger +except ModuleNotFoundError: # pragma: no cover + from ..utils.auth import require_admin_mfa + from ..utils.dynamodb import tables + from ..utils.errors import AppError, ErrorCode + from ..utils.logging import get_logger + +# query_all_items must be typed for mypy (warn_return_any): mypy reads only the +# TYPE_CHECKING branch; the runtime fallback tries the absolute import first so it +# resolves in the Lambda zip (package `utils`). +if TYPE_CHECKING: # pragma: no cover + from ..utils.pagination import query_all_items +else: # pragma: no cover + try: + from utils.pagination import query_all_items + except ModuleNotFoundError: + from ..utils.pagination import query_all_items + +# The decorator stays typed for mypy via the relative import below; at runtime +# the absolute import resolves in the Lambda zip (package `utils`) and the +# relative fallback resolves in unit tests (package `src.handlers`). +if TYPE_CHECKING: # pragma: no cover + from ..utils.handlers import lambda_handler as with_error_handling +else: # pragma: no cover + try: + from utils.handlers import lambda_handler as with_error_handling + except ModuleNotFoundError: + from ..utils.handlers import lambda_handler as with_error_handling + + +def _convert_permissions_to_lists(shares: list[Dict[str, Any]]) -> None: + """Convert DynamoDB permission sets to lists for JSON serialization.""" + for share in shares: + if "permissions" in share and isinstance(share["permissions"], set): + share["permissions"] = list(share["permissions"]) + + +def _query_profile_shares(db_profile_id: str) -> list[Dict[str, Any]]: + """Query all shares for a profile.""" + return query_all_items( + tables.shares, + { + "KeyConditionExpression": "profileId = :pid", + "ExpressionAttributeValues": {":pid": db_profile_id}, + }, + ) + + +def _require_admin_and_get_target_profile_id(event: Dict[str, Any]) -> tuple[str, str]: + """Require admin + MFA, returning ``(db_profile_id, actor_sub)`` (#507). + + ``db_profile_id`` is the target profile named by the request; ``actor_sub`` + is the calling administrator, returned alongside so the audit line can + attribute the operation. + """ + from .admin_operations import _actor_sub + + require_admin_mfa(event) + actor_sub = _actor_sub(event) + + arguments = event.get("arguments", {}) + profile_id = arguments.get("profileId", "").strip() + + if not profile_id: + raise AppError(ErrorCode.INVALID_INPUT, "Profile ID is required") + + # Add PROFILE# prefix if not present + db_profile_id = profile_id if profile_id.startswith("PROFILE#") else f"PROFILE#{profile_id}" + return db_profile_id, actor_sub + + +@with_error_handling(error_message="Failed to get profile shares") +def admin_get_profile_shares(event: Dict[str, Any], context: Any) -> list[Dict[str, Any]]: + """ + Get all shares for a specific profile. + + Admin-only operation. Returns all users who have access to this profile. + """ + logger = get_logger(__name__) + + db_profile_id, actor_sub = _require_admin_and_get_target_profile_id(event) + + # Query shares and convert sets to lists + shares = _query_profile_shares(db_profile_id) + _convert_permissions_to_lists(shares) + + logger.info("Retrieved profile shares", profile_id=db_profile_id, count=len(shares), actor_sub=actor_sub) + return shares + + +def _normalize_profile_and_account_ids(profile_id: str, target_account_id: str) -> tuple[str, str]: + """Normalize profile ID and target account ID with prefixes.""" + db_profile_id = profile_id if profile_id.startswith("PROFILE#") else f"PROFILE#{profile_id}" + db_target_id = target_account_id if target_account_id.startswith("ACCOUNT#") else f"ACCOUNT#{target_account_id}" + return db_profile_id, db_target_id + + +def _require_admin_and_get_share_ids(event: Dict[str, Any]) -> tuple[str, str, str]: + """Require admin + MFA, returning ``(profile_id, target_account_id, actor_sub)`` (#507). + + The first two are the share the request names; the third is the calling + administrator, carried so the revocation can be attributed. + """ + from .admin_operations import _actor_sub + + require_admin_mfa(event) + actor_sub = _actor_sub(event) + + arguments = event.get("arguments", {}) + profile_id = arguments.get("profileId", "").strip() + target_account_id = arguments.get("targetAccountId", "").strip() + + if not profile_id or not target_account_id: + raise AppError(ErrorCode.INVALID_INPUT, "Profile ID and target account ID are required") + + return profile_id, target_account_id, actor_sub + + +@with_error_handling(error_message="Failed to delete share") +def admin_delete_share(event: Dict[str, Any], context: Any) -> bool: + """ + Delete a specific share (revoke access). + + Admin-only operation. Allows admin to remove someone's access to a profile. + """ + logger = get_logger(__name__) + + profile_id, target_account_id, actor_sub = _require_admin_and_get_share_ids(event) + db_profile_id, db_target_id = _normalize_profile_and_account_ids(profile_id, target_account_id) + + tables.shares.delete_item(Key={"profileId": db_profile_id, "targetAccountId": db_target_id}) + + logger.info( + "Deleted share", + profile_id=profile_id, + target_account_id=target_account_id, + actor_sub=actor_sub, + ) + return True + + +def _get_campaign_profile_id(db_campaign_id: str, campaign_id: str) -> str: + """Get profile ID for a campaign via GSI lookup.""" + response = tables.campaigns.query( + IndexName="campaignId-index", + KeyConditionExpression="campaignId = :cid", + ExpressionAttributeValues={":cid": db_campaign_id}, + ) + + items = response.get("Items", []) + if not items: + raise AppError(ErrorCode.NOT_FOUND, f"Campaign not found: {campaign_id}") + + return str(items[0]["profileId"]) + + +def _update_campaign_shared_code( + profile_id: str, db_campaign_id: str, shared_campaign_code: Optional[str] +) -> Dict[str, Any]: + """Update campaign's sharedCampaignCode field and return the stored item.""" + updated_at = datetime.now(timezone.utc).isoformat() + + if shared_campaign_code is None: + update_expr = "REMOVE sharedCampaignCode SET updatedAt = :updated" + expr_vals = {":updated": updated_at} + else: + update_expr = "SET sharedCampaignCode = :code, updatedAt = :updated" + expr_vals = {":code": shared_campaign_code, ":updated": updated_at} + + response = tables.campaigns.update_item( + Key={"profileId": profile_id, "campaignId": db_campaign_id}, + UpdateExpression=update_expr, + ExpressionAttributeValues=expr_vals, + ReturnValues="ALL_NEW", + ) + + return cast(Dict[str, Any], response.get("Attributes", {})) + + +def _require_admin_and_get_target_campaign_id(event: Dict[str, Any]) -> tuple[str, Optional[str], str]: + """Require admin + MFA, returning ``(campaign_id, shared_campaign_code, actor_sub)`` (#507). + + ``campaign_id`` is the target campaign named by the request; ``actor_sub`` + is the calling administrator, carried so the update can be attributed. + """ + from .admin_operations import _actor_sub + + require_admin_mfa(event) + actor_sub = _actor_sub(event) + + arguments = event.get("arguments", {}) + campaign_id = arguments.get("campaignId", "").strip() + shared_campaign_code = arguments.get("sharedCampaignCode") + + if not campaign_id: + raise AppError(ErrorCode.INVALID_INPUT, "Campaign ID is required") + + return campaign_id, shared_campaign_code, actor_sub + + +@with_error_handling(error_message="Failed to update campaign shared code") +def admin_update_campaign_shared_code(event: Dict[str, Any], context: Any) -> Dict[str, Any]: + """ + Update a campaign's sharedCampaignCode field and return the stored campaign item. + + Admin-only operation. Allows associating a campaign with a shared campaign. + """ + logger = get_logger(__name__) + + campaign_id, shared_campaign_code, actor_sub = _require_admin_and_get_target_campaign_id(event) + + # Add CAMPAIGN# prefix if not present + db_campaign_id = campaign_id if campaign_id.startswith("CAMPAIGN#") else f"CAMPAIGN#{campaign_id}" + + # Get campaign profile, update shared code, and return the stored item + profile_id = _get_campaign_profile_id(db_campaign_id, campaign_id) + updated_campaign = _update_campaign_shared_code(profile_id, db_campaign_id, shared_campaign_code) + + logger.info( + "Updated campaign shared code", + campaign_id=campaign_id, + shared_campaign_code=shared_campaign_code, + actor_sub=actor_sub, + ) + return updated_campaign diff --git a/src/handlers/admin_user_deletion.py b/src/handlers/admin_user_deletion.py new file mode 100644 index 00000000..5119636d --- /dev/null +++ b/src/handlers/admin_user_deletion.py @@ -0,0 +1,123 @@ +""" +Admin per-entity user deletion handlers (extracted from admin_operations, #773). + +Provides: +- adminDeleteUserOrders: Delete all orders for a user's campaigns +- adminDeleteUserCampaigns: Delete all campaigns for a user's profiles +- adminDeleteUserShares: Delete all shares for a user's profiles +- adminDeleteUserProfiles: Delete all profiles owned by a user + +The handlers stay registered on the shared admin-operations Lambda: +``admin_operations`` imports them for its operation dispatcher map, so the +Lambda entry point (``handlers.admin_operations.lambda_handler``) and the +AppSync handler mapping are unchanged. +""" + +from typing import TYPE_CHECKING, Any, Dict + +# Sibling handler modules use a same-package relative import, which resolves both +# in the Lambda zip (package `handlers`) and in unit tests (package `src.handlers`). +from .deletion_cascade import ( + delete_user_campaigns, + delete_user_orders, + delete_user_profiles, + delete_user_shares, + get_user_profiles, + normalize_account_id, +) + +# Handle both Lambda (absolute) and unit test (relative) imports +try: # pragma: no cover + from utils.logging import get_logger +except ModuleNotFoundError: # pragma: no cover + from ..utils.logging import get_logger + +# The decorator stays typed for mypy via the relative import below; at runtime +# the absolute import resolves in the Lambda zip (package `utils`) and the +# relative fallback resolves in unit tests (package `src.handlers`). +if TYPE_CHECKING: # pragma: no cover + from ..utils.handlers import lambda_handler as with_error_handling +else: # pragma: no cover + try: + from utils.handlers import lambda_handler as with_error_handling + except ModuleNotFoundError: + from ..utils.handlers import lambda_handler as with_error_handling + + +def _user_profiles_for(account_id: str) -> list[Dict[str, Any]]: + """Sweep the profiles table once for a per-domain admin deletion.""" + return get_user_profiles(normalize_account_id(account_id)) + + +@with_error_handling(error_message="Failed to delete user orders") +def admin_delete_user_orders(event: Dict[str, Any], context: Any) -> int: + """ + Delete all orders for all campaigns of all profiles owned by a user (admin only). + + Verifies with strongly consistent reads that each deleted order is gone + from the orders table before returning success. + + Returns the count of deleted orders. + """ + from .admin_operations import _require_admin_and_get_target_account_id + + logger = get_logger(__name__) + + account_id, actor_sub = _require_admin_and_get_target_account_id(event) + deleted_count = delete_user_orders(_user_profiles_for(account_id), logger) + logger.info("Admin deleted user orders", account_id=account_id, count=deleted_count, actor_sub=actor_sub) + return deleted_count + + +@with_error_handling(error_message="Failed to delete user campaigns") +def admin_delete_user_campaigns(event: Dict[str, Any], context: Any) -> int: + """ + Delete all campaigns for all profiles owned by a user (admin only). + + Verifies with strongly consistent reads that each deleted campaign is gone + from the campaigns table before returning success. + + Returns the count of deleted campaigns. + """ + from .admin_operations import _require_admin_and_get_target_account_id + + logger = get_logger(__name__) + + account_id, actor_sub = _require_admin_and_get_target_account_id(event) + deleted_count = delete_user_campaigns(_user_profiles_for(account_id), logger) + logger.info("Admin deleted user campaigns", account_id=account_id, count=deleted_count, actor_sub=actor_sub) + return deleted_count + + +@with_error_handling(error_message="Failed to delete user shares") +def admin_delete_user_shares(event: Dict[str, Any], context: Any) -> int: + """ + Delete all shares for all profiles owned by a user (admin only). + + Returns the count of deleted shares. + """ + from .admin_operations import _require_admin_and_get_target_account_id + + logger = get_logger(__name__) + + account_id, actor_sub = _require_admin_and_get_target_account_id(event) + deleted_count = delete_user_shares(_user_profiles_for(account_id), logger) + logger.info("Admin deleted user shares", account_id=account_id, count=deleted_count, actor_sub=actor_sub) + return deleted_count + + +@with_error_handling(error_message="Failed to delete user profiles") +def admin_delete_user_profiles(event: Dict[str, Any], context: Any) -> int: + """ + Delete all profiles owned by a user (admin only). + + Returns the count of deleted profiles. + """ + from .admin_operations import _require_admin_and_get_target_account_id + + logger = get_logger(__name__) + + account_id, actor_sub = _require_admin_and_get_target_account_id(event) + deleted_count = delete_user_profiles(account_id, _user_profiles_for(account_id), logger) + logger.info("Admin deleted user profiles", account_id=account_id, count=deleted_count, actor_sub=actor_sub) + return deleted_count diff --git a/src/handlers/admin_user_reads.py b/src/handlers/admin_user_reads.py new file mode 100644 index 00000000..1a6c489c --- /dev/null +++ b/src/handlers/admin_user_reads.py @@ -0,0 +1,287 @@ +""" +Admin user read handlers (extracted from admin_operations, #773). + +Provides: +- adminGetUserProfiles: Get all profiles owned by a user +- adminGetUserCatalogs: Get all catalogs owned by a user +- adminGetUserCampaigns: Get all campaigns across a user's profiles +- adminGetUserSharedCampaigns: Get all shared campaigns created by a user + +The handlers stay registered on the shared admin-operations Lambda: +``admin_operations`` imports them for its operation dispatcher map, so the +Lambda entry point (``handlers.admin_operations.lambda_handler``) and the +AppSync handler mapping are unchanged. + +All four are account-scoped reads guarded by ``_require_admin_and_get_target_account_id``; +the two campaign reads share the ``_batch_get_campaign_catalogs`` chain, which +pre-resolves each campaign's ``catalog`` so the Campaign/SharedCampaign field +resolver short-circuits instead of doing a per-item GetItem (#332). +""" + +from typing import TYPE_CHECKING, Any, Dict + +# Sibling handler modules use a same-package relative import, which resolves both +# in the Lambda zip (package `handlers`) and in unit tests (package `src.handlers`). +from .deletion_cascade import ( + get_user_profiles, + normalize_account_id, +) + +# Handle both Lambda (absolute) and unit test (relative) imports +try: # pragma: no cover + from utils.dynamodb import batch_get_chunked, get_required_env, tables + from utils.logging import get_logger +except ModuleNotFoundError: # pragma: no cover + from ..utils.dynamodb import batch_get_chunked, get_required_env, tables + from ..utils.logging import get_logger + +# The decorator stays typed for mypy via the relative import below; at runtime +# the absolute import resolves in the Lambda zip (package `utils`) and the +# relative fallback resolves in unit tests (package `src.handlers`). +if TYPE_CHECKING: # pragma: no cover + from ..utils.handlers import lambda_handler as with_error_handling +else: # pragma: no cover + try: + from utils.handlers import lambda_handler as with_error_handling + except ModuleNotFoundError: + from ..utils.handlers import lambda_handler as with_error_handling + + +# Handle both Lambda (absolute) and unit test (relative) imports. mypy sees the +# relative path it can resolve; the runtime fallback tries absolute first for Lambda. +if TYPE_CHECKING: # pragma: no cover + from ..utils.pagination import query_all_items +else: # pragma: no cover + try: + from utils.pagination import query_all_items + except ModuleNotFoundError: + from ..utils.pagination import query_all_items + + +@with_error_handling(error_message="Failed to get user profiles") +def admin_get_user_profiles(event: Dict[str, Any], context: Any) -> list[Dict[str, Any]]: + """ + Get all profiles owned by a user (admin only). + + Returns the list of profiles with full details. + """ + from .admin_operations import _require_admin_and_get_target_account_id + + logger = get_logger(__name__) + + account_id, actor_sub = _require_admin_and_get_target_account_id(event) + db_account_id = normalize_account_id(account_id) + + # Query profiles by ownerAccountId + profiles = query_all_items( + tables.profiles, + { + "KeyConditionExpression": "ownerAccountId = :owner", + "ExpressionAttributeValues": {":owner": db_account_id}, + }, + ) + + logger.info("Retrieved user profiles", account_id=account_id, count=len(profiles), actor_sub=actor_sub) + return profiles + + +@with_error_handling(error_message="Failed to get user catalogs") +def admin_get_user_catalogs(event: Dict[str, Any], context: Any) -> list[Dict[str, Any]]: + """ + Get all catalogs owned by a user (admin only). + + Returns the list of catalogs with full details. + """ + from .admin_operations import _require_admin_and_get_target_account_id + + logger = get_logger(__name__) + + account_id, actor_sub = _require_admin_and_get_target_account_id(event) + db_account_id = normalize_account_id(account_id) + + # Query catalogs by ownerAccountId using GSI + catalogs = query_all_items( + tables.catalogs, + { + "IndexName": "ownerAccountId-index", + "KeyConditionExpression": "ownerAccountId = :owner", + "FilterExpression": "attribute_not_exists(isDeleted) OR isDeleted = :false", + "ExpressionAttributeValues": { + ":owner": db_account_id, + ":false": False, + }, + }, + ) + + logger.info("Retrieved user catalogs", account_id=account_id, count=len(catalogs), actor_sub=actor_sub) + return catalogs + + +def _extract_unique_catalog_ids(campaigns: list[Dict[str, Any]]) -> list[str]: + """Extract the de-duplicated catalogIds from a campaign array, preserving first-seen order.""" + catalog_ids: list[str] = [] + seen: set[str] = set() + for campaign in campaigns: + catalog_id = campaign.get("catalogId") + if catalog_id and catalog_id not in seen: + seen.add(catalog_id) + catalog_ids.append(catalog_id) + return catalog_ids + + +def _batch_get_catalog_items( + catalog_ids: list[str], catalogs_table_name: str, logger: Any +) -> Dict[str, Dict[str, Any]]: + """Batch-get the catalogs by id; the shared helper owns the 100-key chunking. + + Raises AppError if keys stay unprocessed after the retries (#557). + """ + catalog_map: Dict[str, Dict[str, Any]] = {} + + def _store_catalog(item: Dict[str, Any]) -> None: + catalog_map[item["catalogId"]] = item + + batch_get_chunked( + catalogs_table_name, + [{"catalogId": catalog_id} for catalog_id in catalog_ids], + _store_catalog, + consistent_read=False, + logger=logger, + ) + return catalog_map + + +def _campaign_catalog_value(catalog: Dict[str, Any] | None, treat_deleted_as_null: bool) -> Dict[str, Any] | None: + """Map a fetched catalog to its Campaign/SharedCampaign catalog contract value.""" + if catalog is not None and treat_deleted_as_null and catalog.get("isDeleted") is True: + return None + return catalog + + +def _attach_campaign_catalogs( + campaigns: list[Dict[str, Any]], catalog_map: Dict[str, Dict[str, Any]], treat_deleted_as_null: bool +) -> None: + """Attach each campaign's fetched catalog, mapping missing/soft-deleted per contract.""" + for campaign in campaigns: + catalog_id = campaign.get("catalogId") + if not catalog_id: + continue + campaign["catalog"] = _campaign_catalog_value(catalog_map.get(catalog_id), treat_deleted_as_null) + + +def _batch_get_campaign_catalogs(campaigns: list[Dict[str, Any]], treat_deleted_as_null: bool, logger: Any) -> None: + """Batch-fetch the catalogs referenced by campaigns and attach each as `catalog`. + + Mirrors the #332 list-query pipeline batching (batch_get_catalogs_fn.js / + batch_get_shared_campaign_catalogs_fn.js) inside the admin Lambda, which + can chunk and loop where AppSync pipeline functions cannot. AppSync always + executes the attached Campaign.catalog / SharedCampaign.catalog field + resolver; since check_source_catalog_fn.js short-circuits when the source + already carries `catalog`, attaching it here replaces the per-item GetItem + (N+1) the admin campaign lists previously paid. + + Deleted-catalog contract matches the field resolvers: Campaign.catalog + returns the raw item including soft-deleted catalogs (treat_deleted_as_null + = False); SharedCampaign.catalog maps soft-deleted or missing catalogs to + None (treat_deleted_as_null = True). + """ + catalog_ids = _extract_unique_catalog_ids(campaigns) + if not catalog_ids: + return + + catalogs_table_name = get_required_env("CATALOGS_TABLE_NAME") + # batch_get_chunked already translates every lookup failure (retryable + # RESOURCE_BUSY on a throttle, INTERNAL_ERROR otherwise), so the typed + # AppError propagates unchanged (#557). + catalog_map = _batch_get_catalog_items(catalog_ids, catalogs_table_name, logger) + + _attach_campaign_catalogs(campaigns, catalog_map, treat_deleted_as_null) + + +def _get_campaigns_for_profiles(profiles: list[Dict[str, Any]], logger: Any, actor_sub: str) -> list[Dict[str, Any]]: + """Query campaigns for each profile.""" + all_campaigns = [] + for profile in profiles: + profile_id = profile.get("profileId") + if profile_id: + campaigns = query_all_items( + tables.campaigns, + { + "KeyConditionExpression": "profileId = :pid", + "ExpressionAttributeValues": {":pid": profile_id}, + }, + ) + all_campaigns.extend(campaigns) + logger.info( + "Retrieved campaigns for profile", + profile_id=profile_id, + count=len(campaigns), + actor_sub=actor_sub, + ) + return all_campaigns + + +@with_error_handling(error_message="Failed to get user campaigns") +def admin_get_user_campaigns(event: Dict[str, Any], context: Any) -> list[Dict[str, Any]]: + """ + Get all campaigns for a user's profiles. + + Admin-only operation. Returns all campaigns across all profiles owned by the account. + """ + from .admin_operations import _require_admin_and_get_target_account_id + + logger = get_logger(__name__) + + account_id, actor_sub = _require_admin_and_get_target_account_id(event) + db_account_id = normalize_account_id(account_id) + + # First, get all profiles owned by this account + profiles = get_user_profiles(db_account_id) + logger.info("Retrieved user profiles", account_id=account_id, count=len(profiles), actor_sub=actor_sub) + + # Now query campaigns for each profile + all_campaigns = _get_campaigns_for_profiles(profiles, logger, actor_sub) + # Pre-resolve catalogs in one chunked BatchGetItem so the Campaign.catalog + # field resolver short-circuits instead of doing per-item GetItem reads (#332). + _batch_get_campaign_catalogs(all_campaigns, treat_deleted_as_null=False, logger=logger) + logger.info("Retrieved user campaigns", account_id=account_id, count=len(all_campaigns), actor_sub=actor_sub) + return all_campaigns + + +@with_error_handling(error_message="Failed to get user shared campaigns") +def admin_get_user_shared_campaigns(event: Dict[str, Any], context: Any) -> list[Dict[str, Any]]: + """ + Get all shared campaigns created by a user. + + Admin-only operation. + """ + from .admin_operations import _require_admin_and_get_target_account_id + + logger = get_logger(__name__) + + account_id, actor_sub = _require_admin_and_get_target_account_id(event) + db_account_id = normalize_account_id(account_id) + + # Query shared campaigns by createdBy using GSI1 + campaigns = query_all_items( + tables.shared_campaigns, + { + "IndexName": "GSI1", + "KeyConditionExpression": "createdBy = :creator", + "ExpressionAttributeValues": { + ":creator": db_account_id, + }, + }, + ) + + # Pre-resolve catalogs in one chunked BatchGetItem so the SharedCampaign.catalog + # field resolver short-circuits instead of doing per-item GetItem reads (#332). + _batch_get_campaign_catalogs(campaigns, treat_deleted_as_null=True, logger=logger) + + logger.info( + "Retrieved user shared campaigns", + account_id=account_id, + count=len(campaigns), + actor_sub=actor_sub, + ) + return campaigns diff --git a/tests/unit/test_admin_operations.py b/tests/unit/test_admin_operations.py index c54e2010..a01dfbb9 100644 --- a/tests/unit/test_admin_operations.py +++ b/tests/unit/test_admin_operations.py @@ -24,13 +24,9 @@ from botocore.exceptions import BotoCoreError, ClientError, EndpointConnectionError from src.handlers.admin_account_purge import admin_purge_user_account -from src.handlers.admin_operations import ( - _batch_get_campaign_catalogs, - _extract_unique_catalog_ids, - admin_reset_user_password, - create_managed_catalog, - lambda_handler, -) +from src.handlers.admin_catalog_operations import create_managed_catalog +from src.handlers.admin_credential_reset import admin_reset_user_password +from src.handlers.admin_operations import lambda_handler from src.handlers.admin_user_operations import ( _ACCOUNT_SCAN_PROJECTION, _ACCOUNTS_SCAN_MAX_PAGES, @@ -42,6 +38,7 @@ admin_list_users, admin_search_user, ) +from src.handlers.admin_user_reads import _batch_get_campaign_catalogs, _extract_unique_catalog_ids from src.utils.errors import AppError, ErrorCode from tests.unit.fixtures import accept_batch_deletes @@ -148,7 +145,7 @@ def test_dispatch_admin_reset_user_password( } # Mock Cognito - with patch("src.handlers.admin_operations.get_cognito_client") as mock_get_client: + with patch("src.handlers.admin_credential_reset.get_cognito_client") as mock_get_client: mock_cognito = MagicMock() mock_cognito.list_users.return_value = {"Users": [{"Username": "test-user-123"}]} mock_cognito.admin_reset_user_password.return_value = {} @@ -966,7 +963,7 @@ def test_success( "arguments": {"email": "test@example.com"}, } - with patch("src.handlers.admin_operations.get_cognito_client") as mock_get_client: + with patch("src.handlers.admin_credential_reset.get_cognito_client") as mock_get_client: mock_cognito = MagicMock() mock_cognito.list_users.return_value = {"Users": [{"Username": "test-user-123"}]} mock_cognito.admin_reset_user_password.return_value = {} @@ -995,7 +992,7 @@ def test_email_trimmed_and_lowercased( "arguments": {"email": " TEST@EXAMPLE.COM "}, } - with patch("src.handlers.admin_operations.get_cognito_client") as mock_get_client: + with patch("src.handlers.admin_credential_reset.get_cognito_client") as mock_get_client: mock_cognito = MagicMock() mock_cognito.list_users.return_value = {"Users": [{"Username": "test-user-123"}]} mock_cognito.admin_reset_user_password.return_value = {} @@ -1067,7 +1064,7 @@ def test_user_not_found( "arguments": {"email": "notfound@example.com"}, } - with patch("src.handlers.admin_operations.get_cognito_client") as mock_get_client: + with patch("src.handlers.admin_credential_reset.get_cognito_client") as mock_get_client: mock_cognito = MagicMock() mock_cognito.list_users.return_value = {"Users": []} mock_get_client.return_value = mock_cognito @@ -1112,7 +1109,7 @@ def test_cognito_list_users_error( "arguments": {"email": "test@example.com"}, } - with patch("src.handlers.admin_operations.get_cognito_client") as mock_get_client: + with patch("src.handlers.admin_credential_reset.get_cognito_client") as mock_get_client: mock_cognito = MagicMock() mock_cognito.list_users.side_effect = ClientError( {"Error": {"Code": "InternalErrorException", "Message": "Test error"}}, @@ -1140,7 +1137,7 @@ def test_cognito_reset_user_not_found( "arguments": {"email": "test@example.com"}, } - with patch("src.handlers.admin_operations.get_cognito_client") as mock_get_client: + with patch("src.handlers.admin_credential_reset.get_cognito_client") as mock_get_client: mock_cognito = MagicMock() mock_cognito.list_users.return_value = {"Users": [{"Username": "test-user-123"}]} mock_cognito.admin_reset_user_password.side_effect = ClientError( @@ -1169,7 +1166,7 @@ def test_cognito_invalid_parameter_exception( "arguments": {"email": "test@example.com"}, } - with patch("src.handlers.admin_operations.get_cognito_client") as mock_get_client: + with patch("src.handlers.admin_credential_reset.get_cognito_client") as mock_get_client: mock_cognito = MagicMock() mock_cognito.list_users.return_value = {"Users": [{"Username": "test-user-123"}]} mock_cognito.admin_reset_user_password.side_effect = ClientError( @@ -1198,7 +1195,7 @@ def test_cognito_other_error_during_reset( "arguments": {"email": "test@example.com"}, } - with patch("src.handlers.admin_operations.get_cognito_client") as mock_get_client: + with patch("src.handlers.admin_credential_reset.get_cognito_client") as mock_get_client: mock_cognito = MagicMock() mock_cognito.list_users.return_value = {"Users": [{"Username": "test-user-123"}]} mock_cognito.admin_reset_user_password.side_effect = ClientError( @@ -1227,7 +1224,7 @@ def test_unexpected_exception_handled( "arguments": {"email": "test@example.com"}, } - with patch("src.handlers.admin_operations.get_cognito_client") as mock_get_client: + with patch("src.handlers.admin_credential_reset.get_cognito_client") as mock_get_client: mock_cognito = MagicMock() mock_cognito.list_users.side_effect = RuntimeError("Unexpected error") mock_get_client.return_value = mock_cognito @@ -1252,7 +1249,7 @@ def test_email_with_quote_rejected_before_filter( "arguments": {"email": 'evil"@example.com'}, } - with patch("src.handlers.admin_operations.get_cognito_client") as mock_get_client: + with patch("src.handlers.admin_credential_reset.get_cognito_client") as mock_get_client: mock_cognito = MagicMock() mock_get_client.return_value = mock_cognito @@ -1277,7 +1274,7 @@ def test_email_with_backslash_rejected_before_filter( "arguments": {"email": "evil\\@example.com\\"}, } - with patch("src.handlers.admin_operations.get_cognito_client") as mock_get_client: + with patch("src.handlers.admin_credential_reset.get_cognito_client") as mock_get_client: mock_cognito = MagicMock() mock_get_client.return_value = mock_cognito @@ -1302,7 +1299,7 @@ def test_malformed_email_rejected_before_filter( "arguments": {"email": "not-an-email"}, } - with patch("src.handlers.admin_operations.get_cognito_client") as mock_get_client: + with patch("src.handlers.admin_credential_reset.get_cognito_client") as mock_get_client: mock_cognito = MagicMock() mock_get_client.return_value = mock_cognito @@ -1367,7 +1364,7 @@ def test_admin_with_mfa_passes( "arguments": {"email": "test@example.com"}, } - with patch("src.handlers.admin_operations.get_cognito_client") as mock_get_client: + with patch("src.handlers.admin_credential_reset.get_cognito_client") as mock_get_client: mock_cognito = MagicMock() mock_cognito.list_users.return_value = {"Users": [{"Username": "test-user-123"}]} mock_cognito.admin_reset_user_password.return_value = {} @@ -2924,7 +2921,7 @@ def test_dynamodb_error_raises( }, } - with patch("src.handlers.admin_operations.tables") as mock_tables: + with patch("src.handlers.admin_catalog_operations.tables") as mock_tables: mock_catalogs = MagicMock() mock_catalogs.put_item.side_effect = ClientError( {"Error": {"Code": "InternalServerError", "Message": "DB error"}}, @@ -2955,7 +2952,7 @@ def test_unexpected_exception_handled( }, } - with patch("src.handlers.admin_operations.tables") as mock_tables: + with patch("src.handlers.admin_catalog_operations.tables") as mock_tables: mock_tables.catalogs.put_item.side_effect = RuntimeError("Unexpected") result = create_managed_catalog(event, lambda_context) @@ -2979,7 +2976,7 @@ def test_success_deletes_all_orders( monkeypatch.setenv("CAMPAIGNS_TABLE_NAME", "kernelworx-campaigns-ue1-dev") monkeypatch.setenv("ORDERS_TABLE_NAME", "kernelworx-orders-ue1-dev") - from src.handlers.admin_operations import admin_delete_user_orders + from src.handlers.admin_user_deletion import admin_delete_user_orders target_account_id = "target-user-123" db_account_id = f"ACCOUNT#{target_account_id}" @@ -3047,7 +3044,7 @@ def test_success_with_campaign_having_no_orders( monkeypatch.setenv("CAMPAIGNS_TABLE_NAME", "kernelworx-campaigns-ue1-dev") monkeypatch.setenv("ORDERS_TABLE_NAME", "kernelworx-orders-ue1-dev") - from src.handlers.admin_operations import admin_delete_user_orders + from src.handlers.admin_user_deletion import admin_delete_user_orders target_account_id = "target-user-123" db_account_id = f"ACCOUNT#{target_account_id}" @@ -3112,7 +3109,7 @@ def test_throttled_returns_resource_busy( from botocore.exceptions import ClientError - from src.handlers.admin_operations import admin_delete_user_orders + from src.handlers.admin_user_deletion import admin_delete_user_orders target_account_id = "target-user-123" db_account_id = f"ACCOUNT#{target_account_id}" @@ -3152,7 +3149,7 @@ def test_non_admin_forbidden( lambda_context: Any, ) -> None: """Test that non-admin users cannot delete user orders.""" - from src.handlers.admin_operations import admin_delete_user_orders + from src.handlers.admin_user_deletion import admin_delete_user_orders event = { **non_admin_appsync_event, @@ -3171,7 +3168,7 @@ def test_missing_account_id( lambda_context: Any, ) -> None: """Test that missing account ID raises error.""" - from src.handlers.admin_operations import admin_delete_user_orders + from src.handlers.admin_user_deletion import admin_delete_user_orders event = { **admin_appsync_event, @@ -3193,14 +3190,14 @@ def test_unexpected_error_handled( """Test that unexpected exceptions are handled.""" monkeypatch.setenv("PROFILES_TABLE_NAME", "kernelworx-profiles-ue1-dev") - from src.handlers.admin_operations import admin_delete_user_orders + from src.handlers.admin_user_deletion import admin_delete_user_orders event = { **admin_appsync_event, "arguments": {"accountId": "target-user-123"}, } - with patch("src.handlers.admin_operations.tables") as mock_tables: + with patch("src.handlers.deletion_cascade.tables") as mock_tables: mock_tables.profiles.query.side_effect = RuntimeError("Unexpected") result = admin_delete_user_orders(event, lambda_context) @@ -3223,7 +3220,7 @@ def test_success_deletes_all_campaigns( monkeypatch.setenv("PROFILES_TABLE_NAME", "kernelworx-profiles-ue1-dev") monkeypatch.setenv("CAMPAIGNS_TABLE_NAME", "kernelworx-campaigns-ue1-dev") - from src.handlers.admin_operations import admin_delete_user_campaigns + from src.handlers.admin_user_deletion import admin_delete_user_campaigns target_account_id = "target-user-123" db_account_id = f"ACCOUNT#{target_account_id}" @@ -3278,7 +3275,7 @@ def test_throttled_returns_resource_busy( from botocore.exceptions import ClientError - from src.handlers.admin_operations import admin_delete_user_campaigns + from src.handlers.admin_user_deletion import admin_delete_user_campaigns target_account_id = "target-user-123" db_account_id = f"ACCOUNT#{target_account_id}" @@ -3312,7 +3309,7 @@ def test_non_admin_forbidden( lambda_context: Any, ) -> None: """Test that non-admin users cannot delete user campaigns.""" - from src.handlers.admin_operations import admin_delete_user_campaigns + from src.handlers.admin_user_deletion import admin_delete_user_campaigns event = { **non_admin_appsync_event, @@ -3331,7 +3328,7 @@ def test_missing_account_id( lambda_context: Any, ) -> None: """Test that missing account ID raises error.""" - from src.handlers.admin_operations import admin_delete_user_campaigns + from src.handlers.admin_user_deletion import admin_delete_user_campaigns event = { **admin_appsync_event, @@ -3357,7 +3354,7 @@ def test_profile_with_no_campaigns_deletes_nothing( """ monkeypatch.setenv("PROFILES_TABLE_NAME", "kernelworx-profiles-ue1-dev") - from src.handlers.admin_operations import admin_delete_user_campaigns + from src.handlers.admin_user_deletion import admin_delete_user_campaigns event = { **admin_appsync_event, @@ -3389,7 +3386,7 @@ def test_unexpected_error_handled( """Test that unexpected exceptions are handled.""" monkeypatch.setenv("PROFILES_TABLE_NAME", "kernelworx-profiles-ue1-dev") - from src.handlers.admin_operations import admin_delete_user_campaigns + from src.handlers.admin_user_deletion import admin_delete_user_campaigns event = { **admin_appsync_event, @@ -3419,7 +3416,7 @@ def test_success_deletes_all_shares( monkeypatch.setenv("PROFILES_TABLE_NAME", "kernelworx-profiles-ue1-dev") monkeypatch.setenv("SHARES_TABLE_NAME", "kernelworx-shares-ue1-dev") - from src.handlers.admin_operations import admin_delete_user_shares + from src.handlers.admin_user_deletion import admin_delete_user_shares target_account_id = "target-user-123" db_account_id = f"ACCOUNT#{target_account_id}" @@ -3467,7 +3464,7 @@ def test_profile_with_no_shares_deletes_nothing( """ monkeypatch.setenv("PROFILES_TABLE_NAME", "kernelworx-profiles-ue1-dev") - from src.handlers.admin_operations import admin_delete_user_shares + from src.handlers.admin_user_deletion import admin_delete_user_shares event = { **admin_appsync_event, @@ -3493,7 +3490,7 @@ def test_non_admin_forbidden( lambda_context: Any, ) -> None: """Test that non-admin users cannot delete user shares.""" - from src.handlers.admin_operations import admin_delete_user_shares + from src.handlers.admin_user_deletion import admin_delete_user_shares event = { **non_admin_appsync_event, @@ -3512,7 +3509,7 @@ def test_missing_account_id( lambda_context: Any, ) -> None: """Test that missing account ID raises error.""" - from src.handlers.admin_operations import admin_delete_user_shares + from src.handlers.admin_user_deletion import admin_delete_user_shares event = { **admin_appsync_event, @@ -3534,7 +3531,7 @@ def test_unexpected_error_handled( """Test that unexpected exceptions are handled.""" monkeypatch.setenv("PROFILES_TABLE_NAME", "kernelworx-profiles-ue1-dev") - from src.handlers.admin_operations import admin_delete_user_shares + from src.handlers.admin_user_deletion import admin_delete_user_shares event = { **admin_appsync_event, @@ -3563,7 +3560,7 @@ def test_success_deletes_all_profiles( """Test successful deletion of all profiles for a user.""" monkeypatch.setenv("PROFILES_TABLE_NAME", "kernelworx-profiles-ue1-dev") - from src.handlers.admin_operations import admin_delete_user_profiles + from src.handlers.admin_user_deletion import admin_delete_user_profiles target_account_id = "target-user-123" db_account_id = f"ACCOUNT#{target_account_id}" @@ -3598,7 +3595,7 @@ def test_non_admin_forbidden( lambda_context: Any, ) -> None: """Test that non-admin users cannot delete user profiles.""" - from src.handlers.admin_operations import admin_delete_user_profiles + from src.handlers.admin_user_deletion import admin_delete_user_profiles event = { **non_admin_appsync_event, @@ -3617,7 +3614,7 @@ def test_missing_account_id( lambda_context: Any, ) -> None: """Test that missing account ID raises error.""" - from src.handlers.admin_operations import admin_delete_user_profiles + from src.handlers.admin_user_deletion import admin_delete_user_profiles event = { **admin_appsync_event, @@ -3639,7 +3636,7 @@ def test_unexpected_error_handled( """Test that unexpected exceptions are handled.""" monkeypatch.setenv("PROFILES_TABLE_NAME", "kernelworx-profiles-ue1-dev") - from src.handlers.admin_operations import admin_delete_user_profiles + from src.handlers.admin_user_deletion import admin_delete_user_profiles event = { **admin_appsync_event, @@ -5611,7 +5608,7 @@ def test_get_user_profiles_paginates( lambda_context: Any, ) -> None: """Test that profile retrieval follows DynamoDB pagination.""" - with patch("src.handlers.admin_operations.query_all_items") as mock_query_all: + with patch("src.handlers.admin_user_reads.query_all_items") as mock_query_all: mock_query_all.return_value = [{"profileId": f"PROFILE#{i}", "sellerName": f"Scout {i}"} for i in range(3)] admin_appsync_event["info"]["fieldName"] = "adminGetUserProfiles" @@ -5725,7 +5722,7 @@ def test_get_user_catalogs_paginates( lambda_context: Any, ) -> None: """Test that catalog retrieval follows DynamoDB pagination.""" - with patch("src.handlers.admin_operations.query_all_items") as mock_query_all: + with patch("src.handlers.admin_user_reads.query_all_items") as mock_query_all: mock_query_all.return_value = [ {"catalogId": f"CATALOG#{i}", "catalogName": f"Catalog {i}"} for i in range(3) ] @@ -5846,7 +5843,7 @@ def test_get_user_campaigns_profile_without_profile_id( with ( patch("src.handlers.deletion_cascade.tables") as mock_cascade_tables, - patch("src.handlers.admin_operations.tables") as mock_tables, + patch("src.handlers.admin_user_reads.tables") as mock_tables, ): # Mock profiles query returning a malformed profile without profileId mock_cascade_tables.profiles.query.return_value = { @@ -6010,7 +6007,7 @@ def test_get_user_shared_campaigns_paginates( lambda_context: Any, ) -> None: """Test that shared campaign retrieval follows DynamoDB pagination.""" - with patch("src.handlers.admin_operations.query_all_items") as mock_query_all: + with patch("src.handlers.admin_user_reads.query_all_items") as mock_query_all: mock_query_all.return_value = [ {"sharedCampaignCode": f"CODE{i}", "campaignName": f"Campaign {i}"} for i in range(3) ] @@ -6038,9 +6035,9 @@ def test_get_user_shared_campaigns_batch_resolves_catalogs( ] with ( - patch("src.handlers.admin_operations.query_all_items", return_value=campaigns), + patch("src.handlers.admin_user_reads.query_all_items", return_value=campaigns), patch("src.utils.dynamodb.get_dynamodb_resource") as mock_resource, - patch("src.handlers.admin_operations.tables") as mock_tables, + patch("src.handlers.admin_user_reads.tables") as mock_tables, ): mock_resource.return_value.batch_get_item.return_value = { "Responses": {"kernelworx-catalogs-ue1-dev": [catalog]}, @@ -6067,7 +6064,7 @@ def test_get_user_shared_campaigns_chunks_batch_get_item( campaigns = [{"sharedCampaignCode": f"CODE{i}", "catalogId": f"CATALOG#{i}"} for i in range(150)] with ( - patch("src.handlers.admin_operations.query_all_items", return_value=campaigns), + patch("src.handlers.admin_user_reads.query_all_items", return_value=campaigns), patch("src.utils.dynamodb.get_dynamodb_resource") as mock_resource, ): mock_resource.return_value.batch_get_item.return_value = { @@ -6299,7 +6296,7 @@ def test_get_profile_shares_permissions_set_conversion( admin_appsync_event["info"]["fieldName"] = "adminGetProfileShares" admin_appsync_event["arguments"] = {"profileId": "test-profile"} - with patch("src.handlers.admin_operations.tables") as mock_tables: + with patch("src.handlers.admin_share_campaign_ops.tables") as mock_tables: # Return shares where permissions is a Python set mock_tables.shares.query.return_value = { "Items": [ @@ -6327,7 +6324,7 @@ def test_get_profile_shares_permissions_already_list( admin_appsync_event["info"]["fieldName"] = "adminGetProfileShares" admin_appsync_event["arguments"] = {"profileId": "test-profile"} - with patch("src.handlers.admin_operations.tables") as mock_tables: + with patch("src.handlers.admin_share_campaign_ops.tables") as mock_tables: # Return shares where permissions is already a list (not a set) mock_tables.shares.query.return_value = { "Items": [ @@ -6367,7 +6364,7 @@ def test_get_profile_shares_paginates( lambda_context: Any, ) -> None: """Test that share retrieval follows DynamoDB pagination.""" - with patch("src.handlers.admin_operations.query_all_items") as mock_query_all: + with patch("src.handlers.admin_share_campaign_ops.query_all_items") as mock_query_all: mock_query_all.return_value = [ { "profileId": "PROFILE#test", @@ -6638,7 +6635,7 @@ def test_get_user_profiles_unexpected_error( admin_appsync_event["info"]["fieldName"] = "adminGetUserProfiles" admin_appsync_event["arguments"] = {"accountId": "test-user"} - with patch("src.handlers.admin_operations.tables") as mock_tables: + with patch("src.handlers.admin_user_reads.tables") as mock_tables: mock_tables.profiles.query.side_effect = RuntimeError("Unexpected failure") result = lambda_handler(admin_appsync_event, lambda_context) @@ -6656,7 +6653,7 @@ def test_get_user_catalogs_unexpected_error( admin_appsync_event["info"]["fieldName"] = "adminGetUserCatalogs" admin_appsync_event["arguments"] = {"accountId": "test-user"} - with patch("src.handlers.admin_operations.tables") as mock_tables: + with patch("src.handlers.admin_user_reads.tables") as mock_tables: mock_tables.catalogs.query.side_effect = RuntimeError("Unexpected failure") result = lambda_handler(admin_appsync_event, lambda_context) @@ -6674,7 +6671,7 @@ def test_get_user_campaigns_unexpected_error( admin_appsync_event["info"]["fieldName"] = "adminGetUserCampaigns" admin_appsync_event["arguments"] = {"accountId": "test-user"} - with patch("src.handlers.admin_operations.tables") as mock_tables: + with patch("src.handlers.admin_user_reads.tables") as mock_tables: mock_tables.profiles.query.side_effect = RuntimeError("Unexpected failure") result = lambda_handler(admin_appsync_event, lambda_context) @@ -6692,7 +6689,7 @@ def test_get_user_shared_campaigns_unexpected_error( admin_appsync_event["info"]["fieldName"] = "adminGetUserSharedCampaigns" admin_appsync_event["arguments"] = {"accountId": "test-user"} - with patch("src.handlers.admin_operations.tables") as mock_tables: + with patch("src.handlers.admin_user_reads.tables") as mock_tables: mock_tables.shared_campaigns.query.side_effect = RuntimeError("Unexpected failure") result = lambda_handler(admin_appsync_event, lambda_context) @@ -6710,7 +6707,7 @@ def test_get_profile_shares_unexpected_error( admin_appsync_event["info"]["fieldName"] = "adminGetProfileShares" admin_appsync_event["arguments"] = {"profileId": "test-profile"} - with patch("src.handlers.admin_operations.tables") as mock_tables: + with patch("src.handlers.admin_share_campaign_ops.tables") as mock_tables: mock_tables.shares.query.side_effect = RuntimeError("Unexpected failure") result = lambda_handler(admin_appsync_event, lambda_context) @@ -6731,7 +6728,7 @@ def test_delete_share_unexpected_error( "targetAccountId": "target-user", } - with patch("src.handlers.admin_operations.tables") as mock_tables: + with patch("src.handlers.admin_share_campaign_ops.tables") as mock_tables: mock_tables.shares.delete_item.side_effect = RuntimeError("Unexpected failure") result = lambda_handler(admin_appsync_event, lambda_context) @@ -6752,7 +6749,7 @@ def test_update_campaign_shared_code_unexpected_error( "sharedCampaignCode": "CODE123", } - with patch("src.handlers.admin_operations.tables") as mock_tables: + with patch("src.handlers.admin_share_campaign_ops.tables") as mock_tables: mock_tables.campaigns.query.side_effect = RuntimeError("Unexpected failure") result = lambda_handler(admin_appsync_event, lambda_context) @@ -7429,12 +7426,12 @@ def test_reset_password_records_the_actor( event = {**admin_appsync_event, "arguments": {"email": "target@example.com"}} with ( - patch("src.handlers.admin_operations.get_cognito_client"), + patch("src.handlers.admin_credential_reset.get_cognito_client"), patch( - "src.handlers.admin_operations._find_user_by_email", + "src.handlers.admin_credential_reset._find_user_by_email", return_value="target", ), - patch("src.handlers.admin_operations._initiate_password_reset"), + patch("src.handlers.admin_credential_reset._initiate_password_reset"), ): assert admin_reset_user_password(event, lambda_context) is True diff --git a/tests/unit/test_cognito_filter_call_sites.py b/tests/unit/test_cognito_filter_call_sites.py index 3f12bd49..6c7b9143 100644 --- a/tests/unit/test_cognito_filter_call_sites.py +++ b/tests/unit/test_cognito_filter_call_sites.py @@ -14,7 +14,13 @@ import pytest -from src.handlers import account_operations, admin_account_purge, admin_operations, admin_user_operations, pre_signup +from src.handlers import ( + account_operations, + admin_account_purge, + admin_credential_reset, + admin_user_operations, + pre_signup, +) from src.utils.errors import AppError, ErrorCode POOL = "us-east-1_TEST123" @@ -55,7 +61,7 @@ ), ( "admin _find_user_by_email", - admin_operations._find_user_by_email, + admin_credential_reset._find_user_by_email, "email", "user@example.com", 'email = "user@example.com"',